diff --git a/.ai/guidelines/fluxui.md b/.ai/guidelines/fluxui.md new file mode 100644 index 0000000..b0f276f --- /dev/null +++ b/.ai/guidelines/fluxui.md @@ -0,0 +1,4 @@ +## FluxUI + +- When building UI, use FluxUI components whenever possible. +- Use custom HTML/components only when FluxUI does not provide an appropriate option for the required behavior or layout. diff --git a/.ai/guidelines/git.md b/.ai/guidelines/git.md new file mode 100644 index 0000000..22a14fa --- /dev/null +++ b/.ai/guidelines/git.md @@ -0,0 +1,5 @@ +## Git + +- Use Conventional Commits for commit messages. +- Format commit subjects as `: `, for example `feat: add misconduct controls` or `fix: correct timeout confirmation state`. +- Prefer standard types such as `feat`, `fix`, `test`, `refactor`, `docs`, `chore`, and `style`. diff --git a/.ai/guidelines/testing.md b/.ai/guidelines/testing.md index 2223896..50e252c 100644 --- a/.ai/guidelines/testing.md +++ b/.ai/guidelines/testing.md @@ -2,6 +2,6 @@ - Never pass attributes directly to `create()` or `make()` in tests. Instead, define named state methods on the factory and chain them in the test (e.g. `->asCaptain()`, `->withRole(...)`). -## Static Analysis +## Rector -- After modifying PHP files, run `vendor/bin/phpstan analyse --memory-limit=256M` and fix any errors before finalizing changes. +- After modifying PHP files, run `vendor/bin/rector process --clear-cache --memory-limit=2G` and fix any issues before finalizing changes. diff --git a/.env.example b/.env.example index c0660ea..9791219 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,6 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +GAME_TIMEOUT_DURATION=30 +GAME_BETWEEN_SETS_DURATION=180 diff --git a/app/Console/Commands/GeneratePdf.php b/app/Console/Commands/GeneratePdf.php index 8877b9c..b1700d5 100644 --- a/app/Console/Commands/GeneratePdf.php +++ b/app/Console/Commands/GeneratePdf.php @@ -6,26 +6,14 @@ use App\Models\Game; use App\Services\Scoresheet\ScoresheetGenerator; +use Illuminate\Console\Attributes\Description; +use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; +#[Description('Command description')] +#[Signature('app:generate-pdf')] class GeneratePdf extends Command { - /** - * The name and signature of the console command. - * - * @var string - */ - #[\Override] - protected $signature = 'app:generate-pdf'; - - /** - * The console command description. - * - * @var string - */ - #[\Override] - protected $description = 'Command description'; - public function handle(ScoresheetGenerator $generator): void { /** @var Game $game */ diff --git a/app/Data/GameState/GameState.php b/app/Data/GameState/GameState.php index 16ef972..206058a 100644 --- a/app/Data/GameState/GameState.php +++ b/app/Data/GameState/GameState.php @@ -5,9 +5,11 @@ namespace App\Data\GameState; use App\Enums\TeamAB; +use App\Enums\TeamSide; use App\Models\GameStateSnapshot; +use Livewire\Wireable; -class GameState +class GameState implements Wireable { /** @var array */ public array $rotationTeamA = []; @@ -25,6 +27,23 @@ public function __construct( public int $timeoutsTeamB = 0, public int $substitutionsTeamA = 0, public int $substitutionsTeamB = 0, + public int $improperRequestsTeamA = 0, + public int $improperRequestsTeamB = 0, + public int $delayWarningsTeamA = 0, + public int $delayWarningsTeamB = 0, + public int $delayPenaltiesTeamA = 0, + public int $delayPenaltiesTeamB = 0, + public int $misconductWarningsTeamA = 0, + public int $misconductWarningsTeamB = 0, + public int $misconductPenaltiesTeamA = 0, + public int $misconductPenaltiesTeamB = 0, + public int $misconductExpulsionsTeamA = 0, + public int $misconductExpulsionsTeamB = 0, + public int $misconductDisqualificationsTeamA = 0, + public int $misconductDisqualificationsTeamB = 0, + public ?TeamSide $teamASide = null, + public ?TeamAB $fifthSetLeftTeam = null, + public bool $fifthSetSideSwapped = false, public ?TeamAB $servingTeam = null, public bool $setInProgress = false, public bool $gameEnded = false, @@ -47,6 +66,23 @@ public static function fromSnapshot(GameStateSnapshot $snapshot): self timeoutsTeamB: $snapshot->timeouts_team_b, substitutionsTeamA: $snapshot->substitutions_team_a, substitutionsTeamB: $snapshot->substitutions_team_b, + improperRequestsTeamA: $snapshot->improper_requests_team_a, + improperRequestsTeamB: $snapshot->improper_requests_team_b, + delayWarningsTeamA: $snapshot->delay_warnings_team_a, + delayWarningsTeamB: $snapshot->delay_warnings_team_b, + delayPenaltiesTeamA: $snapshot->delay_penalties_team_a, + delayPenaltiesTeamB: $snapshot->delay_penalties_team_b, + misconductWarningsTeamA: $snapshot->misconduct_warnings_team_a, + misconductWarningsTeamB: $snapshot->misconduct_warnings_team_b, + misconductPenaltiesTeamA: $snapshot->misconduct_penalties_team_a, + misconductPenaltiesTeamB: $snapshot->misconduct_penalties_team_b, + misconductExpulsionsTeamA: $snapshot->misconduct_expulsions_team_a, + misconductExpulsionsTeamB: $snapshot->misconduct_expulsions_team_b, + misconductDisqualificationsTeamA: $snapshot->misconduct_disqualifications_team_a, + misconductDisqualificationsTeamB: $snapshot->misconduct_disqualifications_team_b, + teamASide: $snapshot->team_a_side, + fifthSetLeftTeam: $snapshot->fifth_set_left_team, + fifthSetSideSwapped: $snapshot->fifth_set_side_swapped, servingTeam: $snapshot->serving_team, setInProgress: $snapshot->set_in_progress, gameEnded: $snapshot->game_ended, @@ -58,10 +94,66 @@ public static function fromSnapshot(GameStateSnapshot $snapshot): self return $state; } + /** + * @param array $attributes + */ + public static function fromAttributes(array $attributes): self + { + $state = new self( + setNumber: self::toInteger($attributes['set_number'] ?? 0), + scoreTeamA: self::toInteger($attributes['score_team_a'] ?? 0), + scoreTeamB: self::toInteger($attributes['score_team_b'] ?? 0), + setsWonTeamA: self::toInteger($attributes['sets_won_team_a'] ?? 0), + setsWonTeamB: self::toInteger($attributes['sets_won_team_b'] ?? 0), + timeoutsTeamA: self::toInteger($attributes['timeouts_team_a'] ?? 0), + timeoutsTeamB: self::toInteger($attributes['timeouts_team_b'] ?? 0), + substitutionsTeamA: self::toInteger($attributes['substitutions_team_a'] ?? 0), + substitutionsTeamB: self::toInteger($attributes['substitutions_team_b'] ?? 0), + improperRequestsTeamA: self::toInteger($attributes['improper_requests_team_a'] ?? 0), + improperRequestsTeamB: self::toInteger($attributes['improper_requests_team_b'] ?? 0), + delayWarningsTeamA: self::toInteger($attributes['delay_warnings_team_a'] ?? 0), + delayWarningsTeamB: self::toInteger($attributes['delay_warnings_team_b'] ?? 0), + delayPenaltiesTeamA: self::toInteger($attributes['delay_penalties_team_a'] ?? 0), + delayPenaltiesTeamB: self::toInteger($attributes['delay_penalties_team_b'] ?? 0), + misconductWarningsTeamA: self::toInteger($attributes['misconduct_warnings_team_a'] ?? 0), + misconductWarningsTeamB: self::toInteger($attributes['misconduct_warnings_team_b'] ?? 0), + misconductPenaltiesTeamA: self::toInteger($attributes['misconduct_penalties_team_a'] ?? 0), + misconductPenaltiesTeamB: self::toInteger($attributes['misconduct_penalties_team_b'] ?? 0), + misconductExpulsionsTeamA: self::toInteger($attributes['misconduct_expulsions_team_a'] ?? 0), + misconductExpulsionsTeamB: self::toInteger($attributes['misconduct_expulsions_team_b'] ?? 0), + misconductDisqualificationsTeamA: self::toInteger($attributes['misconduct_disqualifications_team_a'] ?? 0), + misconductDisqualificationsTeamB: self::toInteger($attributes['misconduct_disqualifications_team_b'] ?? 0), + teamASide: is_string($attributes['team_a_side'] ?? null) + ? TeamSide::tryFrom($attributes['team_a_side']) + : null, + fifthSetLeftTeam: is_string($attributes['fifth_set_left_team'] ?? null) + ? TeamAB::tryFrom($attributes['fifth_set_left_team']) + : null, + fifthSetSideSwapped: (bool) ($attributes['fifth_set_side_swapped'] ?? false), + servingTeam: is_string($attributes['serving_team'] ?? null) + ? TeamAB::tryFrom($attributes['serving_team']) + : null, + setInProgress: (bool) ($attributes['set_in_progress'] ?? false), + gameEnded: (bool) ($attributes['game_ended'] ?? false), + ); + + $rotationTeamA = $attributes['rotation_team_a'] ?? []; + $rotationTeamB = $attributes['rotation_team_b'] ?? []; + + $state->rotationTeamA = is_array($rotationTeamA) + ? self::normalizeRotation($rotationTeamA) + : []; + $state->rotationTeamB = is_array($rotationTeamB) + ? self::normalizeRotation($rotationTeamB) + : []; + + return $state; + } + /** * @return array */ - public function toSnapshotAttributes(): array + public function toAttributes(): array { return [ 'set_number' => $this->setNumber, @@ -73,7 +165,24 @@ public function toSnapshotAttributes(): array 'timeouts_team_b' => $this->timeoutsTeamB, 'substitutions_team_a' => $this->substitutionsTeamA, 'substitutions_team_b' => $this->substitutionsTeamB, - 'serving_team' => $this->servingTeam, + 'improper_requests_team_a' => $this->improperRequestsTeamA, + 'improper_requests_team_b' => $this->improperRequestsTeamB, + 'delay_warnings_team_a' => $this->delayWarningsTeamA, + 'delay_warnings_team_b' => $this->delayWarningsTeamB, + 'delay_penalties_team_a' => $this->delayPenaltiesTeamA, + 'delay_penalties_team_b' => $this->delayPenaltiesTeamB, + 'misconduct_warnings_team_a' => $this->misconductWarningsTeamA, + 'misconduct_warnings_team_b' => $this->misconductWarningsTeamB, + 'misconduct_penalties_team_a' => $this->misconductPenaltiesTeamA, + 'misconduct_penalties_team_b' => $this->misconductPenaltiesTeamB, + 'misconduct_expulsions_team_a' => $this->misconductExpulsionsTeamA, + 'misconduct_expulsions_team_b' => $this->misconductExpulsionsTeamB, + 'misconduct_disqualifications_team_a' => $this->misconductDisqualificationsTeamA, + 'misconduct_disqualifications_team_b' => $this->misconductDisqualificationsTeamB, + 'team_a_side' => $this->teamASide?->value, + 'fifth_set_left_team' => $this->fifthSetLeftTeam?->value, + 'fifth_set_side_swapped' => $this->fifthSetSideSwapped, + 'serving_team' => $this->servingTeam?->value, 'rotation_team_a' => $this->rotationTeamA, 'rotation_team_b' => $this->rotationTeamB, 'set_in_progress' => $this->setInProgress, @@ -81,6 +190,19 @@ public function toSnapshotAttributes(): array ]; } + /** + * @return array + */ + public function toLivewire(): array + { + return $this->toAttributes(); + } + + public static function fromLivewire(mixed $value): self + { + return is_array($value) ? self::fromAttributes($value) : self::initial(); + } + public function resetCurrentSetCounters(): void { $this->scoreTeamA = 0; @@ -107,4 +229,9 @@ private static function normalizeRotation(array $positions): array return $normalized; } + + private static function toInteger(mixed $value): int + { + return is_numeric($value) ? (int) $value : 0; + } } diff --git a/app/Enums/GameEventType.php b/app/Enums/GameEventType.php index 964e465..6bc0a92 100644 --- a/app/Enums/GameEventType.php +++ b/app/Enums/GameEventType.php @@ -9,8 +9,13 @@ enum GameEventType: string case TossCompleted = 'toss_completed'; case LineupSubmitted = 'lineup_submitted'; case RallyEnded = 'rally_ended'; + case CourtSidesSwapped = 'court_sides_swapped'; case SubstitutionCompleted = 'substitution_completed'; case TimeOutRequested = 'time_out_requested'; + case ImproperRequestRecorded = 'improper_request_recorded'; + case DelayWarningRecorded = 'delay_warning_recorded'; + case DelayPenaltyRecorded = 'delay_penalty_recorded'; + case MisconductRecorded = 'misconduct_recorded'; case SetStarted = 'set_started'; case SetEnded = 'set_ended'; case GameEnded = 'game_ended'; diff --git a/app/Enums/ImproperRequestType.php b/app/Enums/ImproperRequestType.php new file mode 100644 index 0000000..dc4665b --- /dev/null +++ b/app/Enums/ImproperRequestType.php @@ -0,0 +1,11 @@ + $data */ + public static function fromArray(array $data): static + { + return new self; + } + + /** @return array */ + public function toArray(): array + { + return []; + } +} diff --git a/app/Events/Payloads/DelayPenaltyRecordedPayload.php b/app/Events/Payloads/DelayPenaltyRecordedPayload.php new file mode 100644 index 0000000..51350c9 --- /dev/null +++ b/app/Events/Payloads/DelayPenaltyRecordedPayload.php @@ -0,0 +1,42 @@ + $data */ + public static function fromArray(array $data): static + { + return new self( + team: TeamAB::from($data['team']), + awardedTeam: TeamAB::from($data['awarded_team']), + requestType: isset($data['request_type']) ? ImproperRequestType::from($data['request_type']) : null, + ); + } + + /** @return array{team: string, awarded_team: string, request_type?: string} */ + public function toArray(): array + { + $payload = [ + 'team' => $this->team->value, + 'awarded_team' => $this->awardedTeam->value, + ]; + + if ($this->requestType !== null) { + $payload['request_type'] = $this->requestType->value; + } + + return $payload; + } +} diff --git a/app/Events/Payloads/DelayWarningRecordedPayload.php b/app/Events/Payloads/DelayWarningRecordedPayload.php new file mode 100644 index 0000000..0b36e3d --- /dev/null +++ b/app/Events/Payloads/DelayWarningRecordedPayload.php @@ -0,0 +1,39 @@ + $data */ + public static function fromArray(array $data): static + { + return new self( + team: TeamAB::from($data['team']), + requestType: isset($data['request_type']) ? ImproperRequestType::from($data['request_type']) : null, + ); + } + + /** @return array{team: string, request_type?: string} */ + public function toArray(): array + { + $payload = [ + 'team' => $this->team->value, + ]; + + if ($this->requestType !== null) { + $payload['request_type'] = $this->requestType->value; + } + + return $payload; + } +} diff --git a/app/Events/Payloads/ImproperRequestRecordedPayload.php b/app/Events/Payloads/ImproperRequestRecordedPayload.php new file mode 100644 index 0000000..0187709 --- /dev/null +++ b/app/Events/Payloads/ImproperRequestRecordedPayload.php @@ -0,0 +1,34 @@ + $data */ + public static function fromArray(array $data): static + { + return new self( + team: TeamAB::from($data['team']), + requestType: ImproperRequestType::from($data['request_type']), + ); + } + + /** @return array{team: string, request_type: string} */ + public function toArray(): array + { + return [ + 'team' => $this->team->value, + 'request_type' => $this->requestType->value, + ]; + } +} diff --git a/app/Events/Payloads/LineupSubmittedPayload.php b/app/Events/Payloads/LineupSubmittedPayload.php index 044c6e1..55cec7c 100644 --- a/app/Events/Payloads/LineupSubmittedPayload.php +++ b/app/Events/Payloads/LineupSubmittedPayload.php @@ -18,9 +18,9 @@ private function __construct( /** * @param array $positions - * @param Collection $validPlayerIds + * @param Collection $validRosterNumbers */ - public static function create(int $set, TeamAB $team, array $positions, Collection $validPlayerIds): static + public static function create(int $set, TeamAB $team, array $positions, Collection $validRosterNumbers): static { if (count($positions) !== 6) { throw new \InvalidArgumentException('A lineup must have exactly 6 positions.'); @@ -33,12 +33,16 @@ public static function create(int $set, TeamAB $team, array $positions, Collecti } if (count(array_unique($positions)) !== 6) { - throw new \InvalidArgumentException('All 6 lineup positions must have different players.'); + throw new \InvalidArgumentException('All 6 lineup positions must have different roster numbers.'); } - foreach ($positions as $playerId) { - if (! $validPlayerIds->contains($playerId)) { - throw new \InvalidArgumentException("Player {$playerId} is not on the roster for the specified team."); + foreach ($positions as $position => $rosterNumber) { + if ($rosterNumber <= 0) { + throw new \InvalidArgumentException("Lineup position {$position} must contain a positive roster number."); + } + + if (! $validRosterNumbers->contains($rosterNumber)) { + throw new \InvalidArgumentException("Roster number {$rosterNumber} is not on the non-libero roster for the specified team."); } } diff --git a/app/Events/Payloads/MisconductRecordedPayload.php b/app/Events/Payloads/MisconductRecordedPayload.php new file mode 100644 index 0000000..2067296 --- /dev/null +++ b/app/Events/Payloads/MisconductRecordedPayload.php @@ -0,0 +1,41 @@ + $data */ + public static function fromArray(array $data): static + { + return new self( + team: TeamAB::from($data['team']), + subjectType: MisconductSubjectType::from($data['subject_type']), + subjectId: (int) $data['subject_id'], + sanction: MisconductSanction::from($data['sanction']), + ); + } + + /** @return array{team: string, subject_type: string, subject_id: int, sanction: string} */ + public function toArray(): array + { + return [ + 'team' => $this->team->value, + 'subject_type' => $this->subjectType->value, + 'subject_id' => $this->subjectId, + 'sanction' => $this->sanction->value, + ]; + } +} diff --git a/app/Events/Payloads/TossCompletedPayload.php b/app/Events/Payloads/TossCompletedPayload.php index e8c776e..cb90850 100644 --- a/app/Events/Payloads/TossCompletedPayload.php +++ b/app/Events/Payloads/TossCompletedPayload.php @@ -12,6 +12,7 @@ public function __construct( public TeamSide $teamA, public TeamAB $serving, + public TeamAB $leftTeam = TeamAB::TeamA, ) {} /** @param array $data */ @@ -20,15 +21,17 @@ public static function fromArray(array $data): static return new self( teamA: TeamSide::from($data['team_a']), serving: TeamAB::from($data['serving']), + leftTeam: TeamAB::from($data['left_team'] ?? TeamAB::TeamA->value), ); } - /** @return array{team_a: string, serving: string} */ + /** @return array{team_a: string, serving: string, left_team: string} */ public function toArray(): array { return [ 'team_a' => $this->teamA->value, 'serving' => $this->serving->value, + 'left_team' => $this->leftTeam->value, ]; } } diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php new file mode 100644 index 0000000..70f422f --- /dev/null +++ b/app/Livewire/Court.php @@ -0,0 +1,456 @@ +gameId = $gameId; + } + + public function render(): View + { + return view('livewire.court', $this->courtContext()); + } + + public function requestDelayWarning(string $team): void + { + $this->pendingDelayWarningTeam = TeamAB::from($team)->value; + + Flux::modal('record-delay-warning-confirm')->show(); + } + + public function requestDelayPenalty(string $team): void + { + $this->pendingDelayPenaltyTeam = TeamAB::from($team)->value; + + Flux::modal('record-delay-penalty-confirm')->show(); + } + + public function requestMisconduct(string $team, string $sanction): void + { + $this->pendingMisconductTeam = TeamAB::from($team)->value; + $this->pendingMisconductSanction = MisconductSanction::from($sanction)->value; + $this->pendingMisconductSubjectType = null; + $this->pendingMisconductSubjectId = null; + $this->pendingMisconductSubjectLabel = null; + + Flux::modal('select-misconduct-subject')->show(); + } + + public function selectMisconductSubject(string $subjectType, int $subjectId): void + { + if ($this->pendingMisconductTeam === null || $this->pendingMisconductSanction === null) { + return; + } + + $this->pendingMisconductSubjectType = MisconductSubjectType::from($subjectType)->value; + $this->pendingMisconductSubjectId = $subjectId; + $this->pendingMisconductSubjectLabel = $this->misconductSubjectLabel( + MisconductSubjectType::from($subjectType), + $subjectId, + ); + + Flux::modal('select-misconduct-subject')->close(); + Flux::modal('record-misconduct-confirm')->show(); + } + + public function recordPendingDelayWarning(): void + { + if ($this->pendingDelayWarningTeam === null || $this->gameId === null) { + return; + } + + $team = TeamAB::from($this->pendingDelayWarningTeam); + $game = Game::query()->findSole($this->gameId); + $game->recordDelayWarning($team); + + $this->pendingDelayWarningTeam = null; + $this->delaySanctionRecordedTitle = 'Delay warning recorded'; + $this->delaySanctionRecordedMessage = "A delay warning has been recorded for {$team->label()}."; + + Flux::modal('record-delay-warning-confirm')->close(); + Flux::modal('delay-sanction-recorded')->show(); + + $this->dispatch('game-event-recorded'); + } + + public function recordPendingDelayPenalty(): void + { + if ($this->pendingDelayPenaltyTeam === null || $this->gameId === null) { + return; + } + + $team = TeamAB::from($this->pendingDelayPenaltyTeam); + $game = Game::query()->findSole($this->gameId); + $game->recordDelayPenalty($team); + + $this->pendingDelayPenaltyTeam = null; + $this->delaySanctionRecordedTitle = 'Delay penalty recorded'; + $this->delaySanctionRecordedMessage = "A delay penalty has been recorded for {$team->label()}."; + + Flux::modal('record-delay-penalty-confirm')->close(); + Flux::modal('delay-sanction-recorded')->show(); + + $this->dispatch('game-event-recorded'); + } + + public function recordPendingMisconduct(): void + { + if ( + $this->pendingMisconductTeam === null + || $this->pendingMisconductSanction === null + || $this->pendingMisconductSubjectType === null + || $this->pendingMisconductSubjectId === null + || $this->gameId === null + ) { + return; + } + + $team = TeamAB::from($this->pendingMisconductTeam); + $sanction = MisconductSanction::from($this->pendingMisconductSanction); + $subjectType = MisconductSubjectType::from($this->pendingMisconductSubjectType); + $game = Game::query()->findSole($this->gameId); + + $game->recordMisconduct($team, $subjectType, $this->pendingMisconductSubjectId, $sanction); + + $this->delaySanctionRecordedTitle = 'Misconduct recorded'; + $this->delaySanctionRecordedMessage = "{$this->misconductSanctionLabel($sanction)} has been recorded for {$this->pendingMisconductSubjectLabel}."; + $this->resetPendingMisconduct(); + + Flux::modal('record-misconduct-confirm')->close(); + Flux::modal('delay-sanction-recorded')->show(); + + $this->dispatch('game-event-recorded'); + } + + public function dismissDelaySanctionRecordedMessage(): void + { + $this->delaySanctionRecordedTitle = null; + $this->delaySanctionRecordedMessage = null; + + Flux::modal('delay-sanction-recorded')->close(); + } + + /** + * @return array{ + * leftTeam: TeamAB, + * rightTeam: TeamAB, + * servingTeam: TeamAB|null, + * showRosters: bool, + * showSanctionControls: bool, + * leftRotation: array, + * rightRotation: array, + * leftDelayWarningDisabled: bool, + * rightDelayWarningDisabled: bool, + * leftDelayPenaltyDisabled: bool, + * rightDelayPenaltyDisabled: bool, + * leftMinorMisconductDisabled: bool, + * rightMinorMisconductDisabled: bool, + * misconductSubjects: array{ + * players: array, + * staff: array + * }, + * pendingMisconductSanctionLabel: string|null + * } + */ + private function courtContext(): array + { + $game = $this->activeGame(); + $state = $this->resolvedGameState(); + $leftTeam = $this->gameSideResolver()->teamOnLeftForState($state); + $rightTeam = $this->gameSideResolver()->teamOnRightForState($state); + $showRosters = $game !== null && $this->gameSideResolver()->hasRecordedToss($game); + $showSanctionControls = ! $this->isBeforeInitialToss($state, $game); + + return [ + 'leftTeam' => $leftTeam, + 'rightTeam' => $rightTeam, + 'servingTeam' => $state->servingTeam, + 'showRosters' => $showRosters, + 'showSanctionControls' => $showSanctionControls, + 'leftRotation' => $this->rotationForTeam($leftTeam), + 'rightRotation' => $this->rotationForTeam($rightTeam), + 'leftDelayWarningDisabled' => $this->hasDelaySanctionFor($leftTeam), + 'rightDelayWarningDisabled' => $this->hasDelaySanctionFor($rightTeam), + 'leftDelayPenaltyDisabled' => ! $this->hasDelaySanctionFor($leftTeam), + 'rightDelayPenaltyDisabled' => ! $this->hasDelaySanctionFor($rightTeam), + 'leftMinorMisconductDisabled' => $this->hasMinorMisconductFor($leftTeam), + 'rightMinorMisconductDisabled' => $this->hasMinorMisconductFor($rightTeam), + 'misconductSubjects' => $this->pendingMisconductSubjects(), + 'pendingMisconductSanctionLabel' => $this->pendingMisconductSanctionLabel(), + ]; + } + + /** + * @return array + */ + private function rotationForTeam(TeamAB $team): array + { + return $team === TeamAB::TeamA + ? $this->resolvedGameState()->rotationTeamA + : $this->resolvedGameState()->rotationTeamB; + } + + private function resolvedGameState(): GameState + { + return $this->gameState ?? GameState::initial(); + } + + private function isBeforeInitialToss(GameState $state, ?Game $game): bool + { + if ($game !== null && $this->gameSideResolver()->hasRecordedToss($game)) { + return false; + } + + return ! $this->gameSideResolver()->hasRequiredToss($state) + && ! $this->gameSideResolver()->requiresFifthSetToss($state); + } + + private function activeGame(): ?Game + { + if ($this->gameId === null) { + return null; + } + + return Game::query()->whereKey($this->gameId)->first(); + } + + private function gameSideResolver(): GameSideResolver + { + return app(GameSideResolver::class); + } + + /** + * @return array{ + * players: array, + * staff: array + * } + */ + private function pendingMisconductSubjects(): array + { + if ($this->pendingMisconductTeam === null) { + return ['players' => [], 'staff' => []]; + } + + $game = $this->activeGame(); + + if ($game === null) { + return ['players' => [], 'staff' => []]; + } + + $team = TeamAB::from($this->pendingMisconductTeam); + $players = $team === TeamAB::TeamA ? $game->homePlayers() : $game->awayPlayers(); + $staff = $team === TeamAB::TeamA ? $game->homeStaff() : $game->awayStaff(); + $sanction = $this->pendingMisconductSanction === null + ? null + : MisconductSanction::from($this->pendingMisconductSanction); + + return [ + 'players' => $players + ->orderByPivot('number') + ->get() + ->map(function (Player $player) use ($game, $team, $sanction): array { + $recordedSanction = $sanction === null + ? null + : $this->blockingMisconductSanction($game, $team, MisconductSubjectType::Player, $player->getKey(), $sanction); + + return [ + 'subject_type' => MisconductSubjectType::Player->value, + 'subject_id' => $player->getKey(), + 'marker' => (string) $player->roster->number, + 'name' => "{$player->first_name} {$player->last_name}", + 'unavailable' => $recordedSanction !== null, + 'unavailable_icon' => $recordedSanction === null ? null : $this->misconductSanctionIcon($recordedSanction), + ]; + }) + ->all(), + 'staff' => $this->staffMisconductSubjects($staff->orderByPivot('id')->get()->all(), $game, $team, $sanction), + ]; + } + + /** + * @param array $staff + * @return array + */ + private function staffMisconductSubjects(array $staff, Game $game, TeamAB $team, ?MisconductSanction $sanction): array + { + $assistantCoachCount = 0; + + return array_map(function (Staff $staffMember) use (&$assistantCoachCount, $game, $team, $sanction): array { + $marker = match ($staffMember->roster->role) { + StaffRole::Coach => 'C', + StaffRole::AssistantCoach => 'AC'.(++$assistantCoachCount), + StaffRole::Doctor => 'D', + StaffRole::Therapist => 'T', + }; + $recordedSanction = $sanction === null + ? null + : $this->blockingMisconductSanction($game, $team, MisconductSubjectType::Staff, $staffMember->getKey(), $sanction); + + return [ + 'subject_type' => MisconductSubjectType::Staff->value, + 'subject_id' => $staffMember->getKey(), + 'marker' => $marker, + 'name' => "{$staffMember->first_name} {$staffMember->last_name}", + 'unavailable' => $recordedSanction !== null, + 'unavailable_icon' => $recordedSanction === null ? null : $this->misconductSanctionIcon($recordedSanction), + ]; + }, $staff); + } + + private function blockingMisconductSanction( + Game $game, + TeamAB $team, + MisconductSubjectType $subjectType, + int $subjectId, + MisconductSanction $sanction, + ): ?MisconductSanction { + /** @var MisconductSanction|null $highestRecordedSanction */ + $highestRecordedSanction = $game->events() + ->where('type', GameEventType::MisconductRecorded) + ->get() + ->map(fn (GameEvent $event): mixed => $event->payload) + ->filter(fn (mixed $payload): bool => $payload instanceof MisconductRecordedPayload + && $payload->team === $team + && $payload->subjectType === $subjectType + && $payload->subjectId === $subjectId) + ->map(fn (MisconductRecordedPayload $payload): MisconductSanction => $payload->sanction) + ->sortByDesc(fn (MisconductSanction $recordedSanction): int => $this->misconductSanctionRank($recordedSanction)) + ->first(); + + if ($highestRecordedSanction === null || $this->misconductSanctionRank($sanction) > $this->misconductSanctionRank($highestRecordedSanction)) { + return null; + } + + return $highestRecordedSanction; + } + + private function misconductSubjectLabel(MisconductSubjectType $subjectType, int $subjectId): ?string + { + $subjects = $this->pendingMisconductSubjects(); + $group = $subjectType === MisconductSubjectType::Player ? $subjects['players'] : $subjects['staff']; + + foreach ($group as $subject) { + if ($subject['subject_id'] === $subjectId) { + return $subject['marker']; + } + } + + return null; + } + + private function pendingMisconductSanctionLabel(): ?string + { + if ($this->pendingMisconductSanction === null) { + return null; + } + + return $this->misconductSanctionLabel(MisconductSanction::from($this->pendingMisconductSanction)); + } + + private function misconductSanctionLabel(MisconductSanction $sanction): string + { + return match ($sanction) { + MisconductSanction::Warning => 'Minor misconduct', + MisconductSanction::Penalty => 'Penalty', + MisconductSanction::Expulsion => 'Expulsion', + MisconductSanction::Disqualification => 'Disqualification', + }; + } + + private function misconductSanctionIcon(MisconductSanction $sanction): string + { + return match ($sanction) { + MisconductSanction::Warning => asset('icons/yellow-card.svg'), + MisconductSanction::Penalty => asset('icons/red-card.svg'), + MisconductSanction::Expulsion => asset('icons/yellow-red-card.svg'), + MisconductSanction::Disqualification => asset('icons/yellow-red-side-by-side-card.svg'), + }; + } + + private function misconductSanctionRank(MisconductSanction $sanction): int + { + return match ($sanction) { + MisconductSanction::Warning => 1, + MisconductSanction::Penalty => 2, + MisconductSanction::Expulsion => 3, + MisconductSanction::Disqualification => 4, + }; + } + + private function resetPendingMisconduct(): void + { + $this->pendingMisconductTeam = null; + $this->pendingMisconductSanction = null; + $this->pendingMisconductSubjectType = null; + $this->pendingMisconductSubjectId = null; + $this->pendingMisconductSubjectLabel = null; + } + + private function hasDelaySanctionFor(TeamAB $team): bool + { + $state = $this->resolvedGameState(); + + if ($team === TeamAB::TeamA) { + return $state->delayWarningsTeamA + $state->delayPenaltiesTeamA > 0; + } + + return $state->delayWarningsTeamB + $state->delayPenaltiesTeamB > 0; + } + + private function hasMinorMisconductFor(TeamAB $team): bool + { + $state = $this->resolvedGameState(); + + if ($team === TeamAB::TeamA) { + return $state->misconductWarningsTeamA > 0; + } + + return $state->misconductWarningsTeamB > 0; + } +} diff --git a/app/Livewire/Game.php b/app/Livewire/Game.php new file mode 100644 index 0000000..991408e --- /dev/null +++ b/app/Livewire/Game.php @@ -0,0 +1,120 @@ +gameId = $game->getKey(); + $this->synchronizeGameContext(); + } + + #[On('game-event-recorded')] + public function synchronizeGameContext(): void + { + $wasSetInProgress = isset($this->gameState) && $this->gameState->setInProgress; + $previousSetNumber = isset($this->gameState) ? $this->gameState->setNumber : 0; + $previousSetsWonTeamA = isset($this->gameState) ? $this->gameState->setsWonTeamA : 0; + $previousScoreTeamA = isset($this->gameState) ? $this->gameState->scoreTeamA : 0; + $previousScoreTeamB = isset($this->gameState) ? $this->gameState->scoreTeamB : 0; + $previousShowFifthSetSideChangePrompt = $this->showFifthSetSideChangePrompt; + + $game = GameModel::query()->findSole($this->gameId); + $this->gameState = $game->stateAt(); + $this->showFifthSetSideChangePrompt = $this->gameSideResolver()->shouldPromptForFifthSetSideSwap($this->gameState); + + if ($wasSetInProgress && ! $this->gameState->setInProgress && ! $this->gameState->gameEnded) { + $winnerIsTeamA = $this->gameState->setsWonTeamA > $previousSetsWonTeamA; + $winnerTeam = $winnerIsTeamA ? TeamAB::TeamA : TeamAB::TeamB; + $this->justEndedSetNumber = $previousSetNumber; + $this->setWinnerCode = $this->countryCodeForTeam($game, $winnerTeam); + $this->finalScoreWinner = $winnerIsTeamA ? $previousScoreTeamA : $previousScoreTeamB; + $this->finalScoreLoser = $winnerIsTeamA ? $previousScoreTeamB : $previousScoreTeamA; + Flux::modal('set-ended')->show(); + } + + if ($this->showFifthSetSideChangePrompt) { + if (! $previousShowFifthSetSideChangePrompt) { + Flux::modal('fifth-set-side-change')->show(); + } + } else { + Flux::modal('fifth-set-side-change')->close(); + } + } + + public function acknowledgeSetEnd(): void + { + $this->justEndedSetNumber = null; + $this->setWinnerCode = null; + $this->finalScoreWinner = null; + $this->finalScoreLoser = null; + Flux::modal('set-ended')->close(); + } + + public function acknowledgeFifthSetSideChange(): void + { + $game = GameModel::query()->findSole($this->gameId); + $game->recordCourtSidesSwapped(); + $this->synchronizeGameContext(); + $this->dispatch('game-event-recorded'); + } + + public function render(): View + { + return view('livewire.game', [ + 'isBeforeInitialToss' => $this->isBeforeInitialToss(), + 'justEndedSetNumber' => $this->justEndedSetNumber, + 'setWinnerCode' => $this->setWinnerCode, + 'finalScoreWinner' => $this->finalScoreWinner, + 'finalScoreLoser' => $this->finalScoreLoser, + 'showFifthSetSideChangePrompt' => $this->showFifthSetSideChangePrompt, + ]); + } + + private function countryCodeForTeam(GameModel $game, TeamAB $team): string + { + $resolver = app(GameSideResolver::class); + + return $resolver->sideForTeam($game, $team) === TeamSide::Home + ? $game->homeTeam->country_code + : $game->awayTeam->country_code; + } + + private function gameSideResolver(): GameSideResolver + { + return app(GameSideResolver::class); + } + + private function isBeforeInitialToss(): bool + { + return ! $this->gameSideResolver()->hasRequiredToss($this->gameState) + && ! $this->gameSideResolver()->requiresFifthSetToss($this->gameState); + } +} diff --git a/app/Livewire/LineupSubmission.php b/app/Livewire/LineupSubmission.php new file mode 100644 index 0000000..610ceb1 --- /dev/null +++ b/app/Livewire/LineupSubmission.php @@ -0,0 +1,322 @@ + */ + public array $lineup = []; + + public function mount(TeamAB $team, ?int $gameId = null, string $courtSide = 'left'): void + { + abort_if(is_null($gameId), 404); + + $this->team = $team; + $this->gameId = $gameId; + $this->courtSide = $courtSide; + $this->lineup = $this->defaultLineup(); + } + + public function modalName(): string + { + return sprintf('submit-lineup-%s-%s', $this->team->value, $this->courtSide); + } + + public function modalHeading(): string + { + return $this->team === TeamAB::TeamA + ? 'Team A Lineup' + : 'Team B Lineup'; + } + + public function submit(): void + { + $this->resetValidation(); + + if (! $this->validateLineup()) { + return; + } + + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->addError('submit', 'No active game is available to record the lineup.'); + + return; + } + + if (! $this->canSubmitLineup()) { + $this->addError('submit', 'A lineup can only be submitted before a set starts when the toss is recorded.'); + + return; + } + + $positions = $this->lineupPositions(); + $eligibleNumbers = $this->eligibleRosterNumbers($activeGame); + + if ($eligibleNumbers !== [] && ! $this->lineupUsesEligibleRosterNumbers($positions, $eligibleNumbers)) { + return; + } + + $set = $this->upcomingSetNumber(); + + try { + $activeGame->recordLineup($set, $this->team, $positions); + } catch (InvalidGameEventTransition|InvalidArgumentException $exception) { + $this->addError('submit', $exception->getMessage()); + + return; + } + + Flux::modal($this->modalName())->close(); + $this->dispatch('game-event-recorded'); + + $this->resetValidation(); + $this->lineup = $this->defaultLineup(); + } + + public function render(): View + { + return view('livewire.lineup-submission', [ + 'canSubmitLineup' => $this->canSubmitLineup(), + 'rosterNumbers' => $this->rosterNumbers(), + ]); + } + + #[Computed] + public function activeGame(): ?Game + { + return Game::query()->whereKey($this->gameId)->first(); + } + + /** + * @return array> + */ + protected function rules(): array + { + return [ + 'lineup' => ['required', 'array', 'size:6'], + 'lineup.*' => ['required', 'integer', 'min:1', 'distinct'], + ]; + } + + /** + * @return array + */ + protected function messages(): array + { + return [ + 'lineup.size' => 'A lineup must contain exactly 6 roster numbers.', + 'lineup.*.required' => 'Each lineup position must have a roster number.', + 'lineup.*.integer' => 'Roster numbers must be positive integers.', + 'lineup.*.min' => 'Roster numbers must be positive integers.', + 'lineup.*.distinct' => 'All lineup roster numbers must be different.', + ]; + } + + /** + * @return array + */ + private function lineupPositions(): array + { + $positions = []; + + foreach (range(1, 6) as $position) { + $positions[$position] = (int) $this->lineup[$position]; + } + + return $positions; + } + + /** + * @return array + */ + private function eligibleRosterNumbers(Game $game): array + { + $teamSide = $this->teamSideForToss(); + + if ($teamSide === null) { + return []; + } + + $rosterNumbers = $teamSide === TeamSide::Home + ? $game->homePlayers()->wherePivot('is_libero', false)->orderByPivot('number')->pluck('game_player.number') + : $game->awayPlayers()->wherePivot('is_libero', false)->orderByPivot('number')->pluck('game_player.number'); + + return $rosterNumbers + ->map(fn (mixed $number): int => (int) $number) + ->all(); + } + + /** + * @return array + */ + private function rosterNumbers(): array + { + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + return []; + } + + return $this->eligibleRosterNumbers($activeGame); + } + + private function teamSideForToss(): ?TeamSide + { + $teamASide = $this->resolvedGameState()->teamASide; + + if ($teamASide === null) { + return null; + } + + if ($this->team === TeamAB::TeamA) { + return $teamASide; + } + + return $teamASide === TeamSide::Home + ? TeamSide::Away + : TeamSide::Home; + } + + /** + * @param array $positions + * @param array $eligibleNumbers + */ + private function lineupUsesEligibleRosterNumbers(array $positions, array $eligibleNumbers): bool + { + foreach ($positions as $position => $rosterNumber) { + if (! in_array($rosterNumber, $eligibleNumbers, true)) { + $this->addError('submit', "Lineup position {$position} is not in this team roster or is a libero."); + + return false; + } + } + + return true; + } + + private function validateLineup(): bool + { + $validator = Validator::make( + data: ['lineup' => $this->lineup], + rules: $this->rules(), + messages: $this->messages(), + )->stopOnFirstFailure(); + + if ($validator->fails()) { + $this->addError('submit', $validator->errors()->first()); + + return false; + } + + return true; + } + + private function canSubmitLineup(): bool + { + if (! $this->hasRequiredToss()) { + return false; + } + + if ($this->isSetInProgress() || $this->isGameEnded()) { + return false; + } + + return ! $this->hasSubmittedLineupForUpcomingSet(); + } + + private function hasSubmittedLineupForUpcomingSet(): bool + { + $state = $this->resolvedGameState(); + + $lineup = $this->team === TeamAB::TeamA + ? $state->rotationTeamA + : $state->rotationTeamB; + + return $lineup !== []; + } + + private function hasRequiredToss(): bool + { + $state = $this->resolvedGameState(); + + return app(GameSideResolver::class)->hasRequiredToss($state); + } + + private function upcomingSetNumber(): int + { + return $this->currentSetNumber() + 1; + } + + private function currentSetNumber(): int + { + return $this->resolvedGameState()->setNumber; + } + + private function isSetInProgress(): bool + { + return $this->resolvedGameState()->setInProgress; + } + + private function isGameEnded(): bool + { + return $this->resolvedGameState()->gameEnded; + } + + private function resolvedGameState(): GameState + { + $activeGame = $this->activeGame(); + + if ($activeGame !== null) { + return $activeGame->stateAt(); + } + + return $this->gameState ?? GameState::initial(); + } + + /** + * @return array + */ + private function defaultLineup(): array + { + return [ + 1 => '', + 2 => '', + 3 => '', + 4 => '', + 5 => '', + 6 => '', + ]; + } +} diff --git a/app/Livewire/RallyWinnerControls.php b/app/Livewire/RallyWinnerControls.php new file mode 100644 index 0000000..fd780a3 --- /dev/null +++ b/app/Livewire/RallyWinnerControls.php @@ -0,0 +1,89 @@ +gameId = $gameId; + $this->side = $side; + } + + public function recordRallyWinner(string $team): void + { + $this->resetValidation('submit'); + + $winningTeam = TeamAB::tryFrom($team); + abort_if($winningTeam === null, 404); + + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->addError('submit', 'No active game is available to record the rally winner.'); + + return; + } + + try { + $activeGame->recordRallyWinner($winningTeam); + } catch (InvalidGameEventTransition $exception) { + $this->addError('submit', $exception->getMessage()); + + return; + } + + $this->dispatch('game-event-recorded'); + } + + public function render(): View + { + $state = $this->gameState ?? GameState::initial(); + $leftTeam = $this->gameSideResolver()->teamOnLeftForState($state); + $rightTeam = $this->gameSideResolver()->teamOnRightForState($state); + + return view('livewire.rally-winner-controls', [ + 'leftTeam' => $leftTeam, + 'rightTeam' => $rightTeam, + 'canRecordRallyWinner' => $state->setInProgress && ! $state->gameEnded, + 'side' => $this->side, + ]); + } + + private function activeGame(): ?Game + { + if ($this->gameId === null) { + return null; + } + + return Game::query()->whereKey($this->gameId)->first(); + } + + private function gameSideResolver(): GameSideResolver + { + return app(GameSideResolver::class); + } +} diff --git a/app/Livewire/RosterSubmission.php b/app/Livewire/RosterSubmission.php new file mode 100644 index 0000000..1db698c --- /dev/null +++ b/app/Livewire/RosterSubmission.php @@ -0,0 +1,667 @@ + */ + public array $homeRosterInputs = []; + + /** @var array */ + public array $awayRosterInputs = []; + + /** @var array */ + public array $homeStaffSelection = []; + + /** @var array */ + public array $awayStaffSelection = []; + + /** @var array */ + public array $homeLiberoSelection = []; + + /** @var array */ + public array $awayLiberoSelection = []; + + public string $homeCaptainSelection = ''; + + public string $awayCaptainSelection = ''; + + public function mount(?int $gameId = null): void + { + $this->gameId = $gameId; + } + + public function updated(string $property): void + { + if ( + str_starts_with($property, 'homeRosterInputs.') + || str_starts_with($property, 'homeLiberoSelection.') + || $property === 'homeCaptainSelection' + ) { + $this->refreshTeamRosterValidation(TeamAB::TeamA); + + return; + } + + if ( + str_starts_with($property, 'awayRosterInputs.') + || str_starts_with($property, 'awayLiberoSelection.') + || $property === 'awayCaptainSelection' + ) { + $this->refreshTeamRosterValidation(TeamAB::TeamB); + } + } + + public function openRosterModal(): void + { + $this->initializeRosterForm(); + $this->resetValidation(); + + Flux::modal('submit-rosters')->show(); + } + + public function submitRosters(): void + { + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->addError('rosters', 'No active game is available to record the rosters.'); + + return; + } + + $homeTeam = $activeGame->homeTeam; + $awayTeam = $activeGame->awayTeam; + + $homePlayers = $homeTeam->players()->orderBy('id')->get(); + $awayPlayers = $awayTeam->players()->orderBy('id')->get(); + $homeStaff = $homeTeam->staff()->orderBy('id')->get(); + $awayStaff = $awayTeam->staff()->orderBy('id')->get(); + + $homePlayerRoster = $this->validatedPlayerRoster( + $homePlayers, + $this->homeRosterInputs, + $this->homeLiberoSelection, + $this->homeCaptainSelection, + 'homeRosterInputs', + ); + $awayPlayerRoster = $this->validatedPlayerRoster( + $awayPlayers, + $this->awayRosterInputs, + $this->awayLiberoSelection, + $this->awayCaptainSelection, + 'awayRosterInputs', + ); + $homeStaffRoster = $this->selectedStaffRoster($homeStaff, $this->homeStaffSelection); + $awayStaffRoster = $this->selectedStaffRoster($awayStaff, $this->awayStaffSelection); + + if ($this->getErrorBag()->isNotEmpty()) { + return; + } + + Flux::modal('submit-rosters')->close(); + Flux::modal('confirm-rosters')->show(); + } + + public function confirmRosters(): void + { + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->addError('rosters', 'No active game is available to record the rosters.'); + + return; + } + + $homeTeam = $activeGame->homeTeam; + $awayTeam = $activeGame->awayTeam; + + $homePlayers = $homeTeam->players()->orderBy('id')->get(); + $awayPlayers = $awayTeam->players()->orderBy('id')->get(); + $homeStaff = $homeTeam->staff()->orderBy('id')->get(); + $awayStaff = $awayTeam->staff()->orderBy('id')->get(); + + $homePlayerRoster = $this->validatedPlayerRoster( + $homePlayers, + $this->homeRosterInputs, + $this->homeLiberoSelection, + $this->homeCaptainSelection, + 'homeRosterInputs', + ); + $awayPlayerRoster = $this->validatedPlayerRoster( + $awayPlayers, + $this->awayRosterInputs, + $this->awayLiberoSelection, + $this->awayCaptainSelection, + 'awayRosterInputs', + ); + $homeStaffRoster = $this->selectedStaffRoster($homeStaff, $this->homeStaffSelection); + $awayStaffRoster = $this->selectedStaffRoster($awayStaff, $this->awayStaffSelection); + + if ($this->getErrorBag()->isNotEmpty()) { + Flux::modal('confirm-rosters')->close(); + Flux::modal('submit-rosters')->show(); + + return; + } + + $activeGame->replaceRosterForTeam(TeamAB::TeamA, $homePlayerRoster, $homeStaffRoster); + $activeGame->replaceRosterForTeam(TeamAB::TeamB, $awayPlayerRoster, $awayStaffRoster); + $activeGame->markRostersSubmitted(); + + Flux::modal('confirm-rosters')->close(); + $this->resetValidation(); + $this->dispatch('game-event-recorded'); + } + + public function returnToRoster(): void + { + Flux::modal('confirm-rosters')->close(); + Flux::modal('submit-rosters')->show(); + } + + public function render(): View + { + $activeGame = $this->activeGame(); + $homePlayers = $activeGame?->homeTeam->players()->orderBy('id')->get() ?? new EloquentCollection; + $awayPlayers = $activeGame?->awayTeam->players()->orderBy('id')->get() ?? new EloquentCollection; + $homeStaff = $activeGame?->homeTeam->staff()->orderBy('id')->get() ?? new EloquentCollection; + $awayStaff = $activeGame?->awayTeam->staff()->orderBy('id')->get() ?? new EloquentCollection; + + return view('livewire.roster-submission', [ + 'hasSubmittedRosters' => $this->hasSubmittedRosters($activeGame), + 'isBeforeInitialToss' => $this->isBeforeInitialToss($activeGame), + 'homeTeamCode' => $activeGame?->homeTeam->country_code ?? 'Home Team', + 'awayTeamCode' => $activeGame?->awayTeam->country_code ?? 'Away Team', + 'homePlayers' => $homePlayers, + 'awayPlayers' => $awayPlayers, + 'homeStaff' => $homeStaff, + 'awayStaff' => $awayStaff, + 'homeRosterConfirmation' => $this->confirmationRosterForTeam( + $homePlayers, + $homeStaff, + $this->homeRosterInputs, + $this->homeLiberoSelection, + $this->homeCaptainSelection, + $this->homeStaffSelection, + ), + 'awayRosterConfirmation' => $this->confirmationRosterForTeam( + $awayPlayers, + $awayStaff, + $this->awayRosterInputs, + $this->awayLiberoSelection, + $this->awayCaptainSelection, + $this->awayStaffSelection, + ), + ]); + } + + private function hasSubmittedRosters(?Game $activeGame = null): bool + { + $game = $activeGame ?? $this->activeGame(); + + return $game?->hasSubmittedInitialRosters() ?? false; + } + + private function isBeforeInitialToss(?Game $activeGame = null): bool + { + $state = $this->resolvedGameState($activeGame); + + return ! $this->gameSideResolver()->hasRequiredToss($state) + && ! $this->gameSideResolver()->requiresFifthSetToss($state); + } + + private function initializeRosterForm(): void + { + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->homeRosterInputs = []; + $this->awayRosterInputs = []; + $this->homeStaffSelection = []; + $this->awayStaffSelection = []; + $this->homeLiberoSelection = []; + $this->awayLiberoSelection = []; + $this->homeCaptainSelection = ''; + $this->awayCaptainSelection = ''; + + return; + } + + $this->homeRosterInputs = $this->existingRosterInputs( + $activeGame->homeTeam->players()->orderBy('id')->get(), + $activeGame->homePlayers()->get(), + ); + $this->awayRosterInputs = $this->existingRosterInputs( + $activeGame->awayTeam->players()->orderBy('id')->get(), + $activeGame->awayPlayers()->get(), + ); + $this->homeStaffSelection = $this->existingStaffSelection( + $activeGame->homeTeam->staff()->orderBy('id')->get(), + $activeGame->homeStaff()->get(), + ); + $this->awayStaffSelection = $this->existingStaffSelection( + $activeGame->awayTeam->staff()->orderBy('id')->get(), + $activeGame->awayStaff()->get(), + ); + $this->homeLiberoSelection = $this->existingLiberoSelection( + $activeGame->homeTeam->players()->orderBy('id')->get(), + $activeGame->homePlayers()->get(), + ); + $this->awayLiberoSelection = $this->existingLiberoSelection( + $activeGame->awayTeam->players()->orderBy('id')->get(), + $activeGame->awayPlayers()->get(), + ); + $this->homeCaptainSelection = $this->existingCaptainSelection( + $activeGame->homePlayers()->get(), + ); + $this->awayCaptainSelection = $this->existingCaptainSelection( + $activeGame->awayPlayers()->get(), + ); + } + + private function refreshTeamRosterValidation(TeamAB $team): void + { + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + return; + } + + if ($team === TeamAB::TeamA) { + $fieldPrefix = 'homeRosterInputs'; + $teamPlayers = $activeGame->homeTeam->players()->orderBy('id')->get(); + $inputs = $this->homeRosterInputs; + $liberoSelection = $this->homeLiberoSelection; + $captainSelection = $this->homeCaptainSelection; + } else { + $fieldPrefix = 'awayRosterInputs'; + $teamPlayers = $activeGame->awayTeam->players()->orderBy('id')->get(); + $inputs = $this->awayRosterInputs; + $liberoSelection = $this->awayLiberoSelection; + $captainSelection = $this->awayCaptainSelection; + } + + $this->resetValidation($this->validationFieldsForTeam($teamPlayers, $fieldPrefix, $this->captainFieldPrefix($team))); + + $this->validatedPlayerRoster($teamPlayers, $inputs, $liberoSelection, $captainSelection, $fieldPrefix); + } + + /** + * @param EloquentCollection $teamPlayers + * @param array $inputs + * @param array $liberoSelection + * @return array + */ + private function validatedPlayerRoster(EloquentCollection $teamPlayers, array $inputs, array $liberoSelection, string $captainSelection, string $fieldPrefix): array + { + $selectedPlayers = []; + $rosteredPlayerIds = []; + $selectedNumbers = []; + $liberoCount = 0; + $nonLiberoCount = 0; + + foreach ($teamPlayers as $player) { + $input = trim($inputs[$player->getKey()] ?? ''); + $isLibero = (bool) ($liberoSelection[$player->getKey()] ?? false); + + if ($input === '') { + if ($isLibero) { + $this->addError("{$fieldPrefix}.{$player->getKey()}", 'Libero must be a rostered player.'); + } + + continue; + } + + if (! preg_match('/^\d+$/', $input)) { + $this->addError("{$fieldPrefix}.{$player->getKey()}", 'Use a roster number between 1 and 99.'); + + continue; + } + + $number = (int) $input; + + if ($number < 1 || $number > 99) { + $this->addError("{$fieldPrefix}.{$player->getKey()}", 'Roster numbers must be between 1 and 99.'); + + continue; + } + + if (in_array($number, $selectedNumbers, true)) { + $this->addError("{$fieldPrefix}.{$player->getKey()}", 'Roster numbers must be unique within the team.'); + + continue; + } + + $selectedNumbers[] = $number; + $rosteredPlayerIds[] = $player->getKey(); + $selectedPlayers[] = [ + 'player' => $player, + 'number' => $number, + 'is_captain' => false, + 'is_libero' => $isLibero, + ]; + + if ($isLibero) { + $liberoCount++; + } else { + $nonLiberoCount++; + } + } + + if ($liberoCount > 2) { + $this->addError($fieldPrefix, 'A team can have at most two liberos.'); + } + + if ($nonLiberoCount < 6) { + $this->addError($fieldPrefix, 'A team must have at least six non-libero players.'); + } + + if ($nonLiberoCount > 12) { + $this->addError($fieldPrefix, 'A team can have at most 12 non-libero players.'); + } + + if ($captainSelection === '') { + $this->addError($this->captainFieldFromRosterField($fieldPrefix), 'Select a captain.'); + + return $selectedPlayers; + } + + $captainPlayerId = (int) $captainSelection; + if (! in_array($captainPlayerId, $rosteredPlayerIds, true)) { + $this->addError($this->captainFieldFromRosterField($fieldPrefix), 'Captain must be one of the rostered players.'); + + return $selectedPlayers; + } + + foreach ($selectedPlayers as $index => $selectedPlayer) { + if ($selectedPlayer['player']->getKey() === $captainPlayerId) { + $selectedPlayers[$index]['is_captain'] = true; + + break; + } + } + + return $selectedPlayers; + } + + /** + * @param EloquentCollection $teamStaff + * @param array $selection + * @return array + */ + private function selectedStaffRoster(EloquentCollection $teamStaff, array $selection): array + { + return $teamStaff + ->filter(fn (Staff $staff): bool => (bool) ($selection[$staff->getKey()] ?? false)) + ->values() + ->all(); + } + + /** + * @param EloquentCollection $teamPlayers + * @param EloquentCollection $rosteredPlayers + * @return array + */ + private function existingRosterInputs(EloquentCollection $teamPlayers, EloquentCollection $rosteredPlayers): array + { + $inputs = []; + + foreach ($teamPlayers as $player) { + /** @var Player|null $rosteredPlayer */ + $rosteredPlayer = $rosteredPlayers->firstWhere($player->getKeyName(), $player->getKey()); + + $inputs[$player->getKey()] = $rosteredPlayer === null + ? '' + : (string) $rosteredPlayer->roster->number; + } + + return $inputs; + } + + /** + * @param EloquentCollection $teamPlayers + * @param EloquentCollection $rosteredPlayers + * @return array + */ + private function existingLiberoSelection(EloquentCollection $teamPlayers, EloquentCollection $rosteredPlayers): array + { + $selection = []; + + foreach ($teamPlayers as $player) { + /** @var Player|null $rosteredPlayer */ + $rosteredPlayer = $rosteredPlayers->firstWhere($player->getKeyName(), $player->getKey()); + + $selection[$player->getKey()] = $rosteredPlayer?->roster->is_libero ?? false; + } + + return $selection; + } + + /** + * @param EloquentCollection $rosteredPlayers + */ + private function existingCaptainSelection(EloquentCollection $rosteredPlayers): string + { + /** @var Player|null $captain */ + $captain = $rosteredPlayers->first(fn (Player $player): bool => $player->roster->is_captain); + + return $captain === null ? '' : (string) $captain->getKey(); + } + + /** + * @param EloquentCollection $teamStaff + * @param EloquentCollection $rosteredStaff + * @return array + */ + private function existingStaffSelection(EloquentCollection $teamStaff, EloquentCollection $rosteredStaff): array + { + $selection = []; + + foreach ($teamStaff as $staffMember) { + $selection[$staffMember->getKey()] = $rosteredStaff + ->contains(fn (Staff $rosteredStaffMember): bool => $rosteredStaffMember->getKey() === $staffMember->getKey()); + } + + return $selection; + } + + private function resolvedGameState(?Game $activeGame = null): GameState + { + if ($activeGame !== null) { + return $activeGame->stateAt(); + } + + if ($this->gameId === null) { + return $this->gameState ?? GameState::initial(); + } + + $activeGame = Game::query()->whereKey($this->gameId)->first(); + + return $activeGame?->stateAt() ?? ($this->gameState ?? GameState::initial()); + } + + private function activeGame(): ?Game + { + if ($this->gameId === null) { + return null; + } + + return Game::query() + ->with(['homeTeam', 'awayTeam']) + ->whereKey($this->gameId) + ->first(); + } + + private function gameSideResolver(): GameSideResolver + { + return app(GameSideResolver::class); + } + + private function captainFieldPrefix(TeamAB $team): string + { + return $team === TeamAB::TeamA + ? 'homeCaptainSelection' + : 'awayCaptainSelection'; + } + + private function captainFieldFromRosterField(string $fieldPrefix): string + { + return $fieldPrefix === 'homeRosterInputs' + ? 'homeCaptainSelection' + : 'awayCaptainSelection'; + } + + /** + * @param EloquentCollection $teamPlayers + * @return array + */ + private function validationFieldsForTeam(EloquentCollection $teamPlayers, string $fieldPrefix, string $captainField): array + { + $fields = [$fieldPrefix, $captainField]; + + foreach ($teamPlayers as $player) { + $fields[] = "{$fieldPrefix}.{$player->getKey()}"; + } + + return $fields; + } + + /** + * @param EloquentCollection $teamPlayers + * @param EloquentCollection $teamStaff + * @param array $inputs + * @param array $liberoSelection + * @param array $staffSelection + * @return array{ + * players: Collection, + * liberos: Collection, + * bench: Collection + * } + */ + private function confirmationRosterForTeam( + EloquentCollection $teamPlayers, + EloquentCollection $teamStaff, + array $inputs, + array $liberoSelection, + string $captainSelection, + array $staffSelection, + ): array { + $captainPlayerId = $captainSelection === '' ? null : (int) $captainSelection; + + $rosteredPlayers = $teamPlayers + ->map(function (Player $player) use ($inputs, $liberoSelection, $captainPlayerId): ?array { + $input = trim($inputs[$player->getKey()] ?? ''); + + if ($input === '' || ! preg_match('/^\d+$/', $input)) { + return null; + } + + $number = (int) $input; + + if ($number < 1 || $number > 99) { + return null; + } + + return [ + 'name' => $this->formattedPersonName($player->last_name, $player->first_name), + 'number' => $number, + 'is_captain' => $captainPlayerId !== null && $player->getKey() === $captainPlayerId, + 'is_libero' => (bool) ($liberoSelection[$player->getKey()] ?? false), + ]; + }) + ->filter() + ->sortBy('number') + ->values(); + + $selectedStaff = $teamStaff + ->filter(fn (Staff $staff): bool => (bool) ($staffSelection[$staff->getKey()] ?? false)) + ->sortBy(fn (Staff $staff): array => [ + $this->staffOrder($staff->role), + $staff->role === StaffRole::AssistantCoach ? mb_strtolower($staff->last_name) : '', + mb_strtolower($staff->first_name), + ]) + ->values(); + + $assistantCoachCount = 0; + + $selectedStaff = $selectedStaff + ->map(function (Staff $staff) use (&$assistantCoachCount): array { + return [ + 'name' => $this->formattedPersonName($staff->last_name, $staff->first_name), + 'role' => $this->staffRoleCode($staff->role, $assistantCoachCount), + ]; + }) + ->values(); + + return [ + 'players' => $rosteredPlayers + ->reject(fn (array $player): bool => $player['is_libero']) + ->map(fn (array $player): array => [ + 'name' => $player['name'], + 'number' => $player['number'], + 'is_captain' => $player['is_captain'], + ]) + ->values(), + 'liberos' => $rosteredPlayers + ->filter(fn (array $player): bool => $player['is_libero']) + ->map(fn (array $player): array => [ + 'name' => $player['name'], + 'number' => $player['number'], + ]) + ->values(), + 'bench' => $selectedStaff, + ]; + } + + private function formattedPersonName(string $lastName, string $firstName): string + { + $firstInitial = mb_substr($firstName, 0, 1); + + return "{$lastName} {$firstInitial}."; + } + + private function staffOrder(StaffRole $role): int + { + return match ($role) { + StaffRole::Coach => 0, + StaffRole::AssistantCoach => 1, + StaffRole::Therapist => 2, + StaffRole::Doctor => 3, + }; + } + + private function staffRoleCode(StaffRole $role, int &$assistantCoachCount): string + { + return match ($role) { + StaffRole::Coach => 'C', + StaffRole::AssistantCoach => 'AC'.(++$assistantCoachCount), + StaffRole::Therapist => 'T', + StaffRole::Doctor => 'D', + }; + } +} diff --git a/app/Livewire/Scoreboard.php b/app/Livewire/Scoreboard.php new file mode 100644 index 0000000..74f3c9f --- /dev/null +++ b/app/Livewire/Scoreboard.php @@ -0,0 +1,90 @@ +gameState ?? GameState::initial(); + $leftTeam = $this->gameSideResolver()->teamOnLeftForState($scoreboardState); + $rightTeam = $this->gameSideResolver()->teamOnRightForState($scoreboardState); + $teamCodes = $this->teamCountryCodes(); + + return view('livewire.scoreboard', [ + 'leftTeam' => $leftTeam, + 'rightTeam' => $rightTeam, + 'leftTeamCode' => $leftTeam === TeamAB::TeamA ? $teamCodes['team_a'] : $teamCodes['team_b'], + 'rightTeamCode' => $rightTeam === TeamAB::TeamA ? $teamCodes['team_a'] : $teamCodes['team_b'], + 'leftSets' => $leftTeam === TeamAB::TeamA ? $scoreboardState->setsWonTeamA : $scoreboardState->setsWonTeamB, + 'rightSets' => $rightTeam === TeamAB::TeamA ? $scoreboardState->setsWonTeamA : $scoreboardState->setsWonTeamB, + 'leftPoints' => $leftTeam === TeamAB::TeamA ? $scoreboardState->scoreTeamA : $scoreboardState->scoreTeamB, + 'rightPoints' => $rightTeam === TeamAB::TeamA ? $scoreboardState->scoreTeamA : $scoreboardState->scoreTeamB, + ]); + } + + /** + * @return array{team_a: string, team_b: string} + */ + #[Computed] + public function teamCountryCodes(): array + { + $game = $this->activeGame(); + + if ($game === null) { + return [ + 'team_a' => TeamAB::TeamA->label(), + 'team_b' => TeamAB::TeamB->label(), + ]; + } + + if ($this->gameSideResolver()->sideForTeam($game, TeamAB::TeamA) === TeamSide::Home) { + return [ + 'team_a' => $game->homeTeam->country_code, + 'team_b' => $game->awayTeam->country_code, + ]; + } + + return [ + 'team_a' => $game->awayTeam->country_code, + 'team_b' => $game->homeTeam->country_code, + ]; + } + + private function activeGame(): ?Game + { + if ($this->gameId === null) { + return null; + } + + return Game::query() + ->with(['homeTeam', 'awayTeam']) + ->whereKey($this->gameId) + ->first(); + } + + private function gameSideResolver(): GameSideResolver + { + return app(GameSideResolver::class); + } +} diff --git a/app/Livewire/StartSetSubmission.php b/app/Livewire/StartSetSubmission.php new file mode 100644 index 0000000..636e5a2 --- /dev/null +++ b/app/Livewire/StartSetSubmission.php @@ -0,0 +1,211 @@ +gameId = $gameId; + } + + public function startSet(): void + { + $this->resetValidation('startSet'); + + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->addError('startSet', 'No active game is available to start the set.'); + + return; + } + + if ($this->setBreakIsInProgress()) { + $this->addError('startSet', 'The interval between sets has not elapsed yet.'); + + return; + } + + if (! $this->hasStartSetPrerequisites()) { + $this->addError('startSet', 'Both team lineups must be submitted before starting the set.'); + + return; + } + + try { + $activeGame->recordSetStarted(); + } catch (InvalidGameEventTransition $exception) { + $this->addError('startSet', $exception->getMessage()); + + return; + } + + $this->dispatch('game-event-recorded'); + } + + public function render(): View + { + $setBreakRemainingSeconds = $this->setBreakRemainingSeconds(); + $upcomingSetNumber = $this->upcomingSetNumber(); + + return view('livewire.start-set-submission', [ + 'canStartSet' => $this->canStartSet(), + 'showSetBreakCountdown' => $setBreakRemainingSeconds > 0, + 'setBreakRemainingSeconds' => $setBreakRemainingSeconds, + 'setBreakCountdownLabel' => $this->formatDuration($setBreakRemainingSeconds), + 'showStartGameButton' => $this->canStartSet() && $upcomingSetNumber === 1, + 'shouldAutoStartSet' => $this->canStartSet() && $upcomingSetNumber > 1, + 'upcomingSetNumber' => $upcomingSetNumber, + ]); + } + + private function canStartSet(): bool + { + return $this->hasStartSetPrerequisites() && ! $this->setBreakIsInProgress(); + } + + private function hasStartSetPrerequisites(): bool + { + if ($this->activeGame() === null) { + return false; + } + + $activeGameState = $this->activeGameState(); + + if (! $this->hasSubmittedToss($activeGameState)) { + return false; + } + + if ($activeGameState->setInProgress || $activeGameState->gameEnded) { + return false; + } + + return $this->bothLineupsSubmittedForUpcomingSet($activeGameState); + } + + #[Computed] + public function activeGame(): ?Game + { + if ($this->gameId === null) { + return null; + } + + return Game::query()->whereKey($this->gameId)->first(); + } + + #[Computed] + public function activeGameState(): GameState + { + $activeGame = $this->activeGame(); + + return $activeGame?->stateAt() ?? $this->resolvedGameState(); + } + + private function hasSubmittedToss(GameState $state): bool + { + return app(GameSideResolver::class)->hasRequiredToss($state); + } + + private function bothLineupsSubmittedForUpcomingSet(GameState $state): bool + { + return $this->hasSubmittedLineupForTeam($state, TeamAB::TeamA) + && $this->hasSubmittedLineupForTeam($state, TeamAB::TeamB); + } + + private function hasSubmittedLineupForTeam(GameState $state, TeamAB $team): bool + { + $lineup = $team === TeamAB::TeamA + ? $state->rotationTeamA + : $state->rotationTeamB; + + return $lineup !== []; + } + + private function upcomingSetNumber(): int + { + return $this->activeGameState()->setNumber + 1; + } + + private function setBreakIsInProgress(): bool + { + return $this->setBreakRemainingSeconds() > 0; + } + + private function setBreakRemainingSeconds(): int + { + $activeGame = $this->activeGame(); + $state = $this->activeGameState(); + + if ($activeGame === null || $state->setNumber === 0 || $state->gameEnded) { + return 0; + } + + $lastSetEndedAt = $this->lastSetEndedAt($activeGame); + + if ($lastSetEndedAt === null) { + return 0; + } + + $remainingSeconds = now()->diffInSeconds( + $lastSetEndedAt->addSeconds($this->betweenSetsDuration()), + false, + ); + + return max(0, (int) $remainingSeconds); + } + + private function lastSetEndedAt(Game $game): ?CarbonImmutable + { + /** @var GameEvent|null $setEndedEvent */ + $setEndedEvent = $game->events() + ->reorder() + ->where('type', GameEventType::SetEnded) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + + return $setEndedEvent?->created_at; + } + + private function betweenSetsDuration(): int + { + return max(0, (int) config('game.between_sets_duration', 180)); + } + + private function formatDuration(int $seconds): string + { + $minutes = intdiv($seconds, 60); + $remainingSeconds = $seconds % 60; + + return sprintf('%02d:%02d', $minutes, $remainingSeconds); + } + + private function resolvedGameState(): GameState + { + return $this->gameState ?? GameState::initial(); + } +} diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php new file mode 100644 index 0000000..497a2f7 --- /dev/null +++ b/app/Livewire/TeamRoster.php @@ -0,0 +1,470 @@ +gameId = $gameId; + $this->team = $team; + $this->leftSide = $leftSide; + } + + public string $playerOut = ''; + + public string $playerIn = ''; + + public function requestTimeout(): void + { + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->addError('timeout', 'No active game is available to record the timeout.'); + + return; + } + + $state = $activeGame->stateAt(); + $timeoutsTaken = $this->team === TeamAB::TeamA ? $state->timeoutsTeamA : $state->timeoutsTeamB; + $hasTimeoutLeft = $timeoutsTaken < 2; + + $recordedRequestType = null; + + try { + if ($hasTimeoutLeft) { + $activeGame->recordTimeOut($this->team); + } else { + $recordedRequestType = $activeGame->recordImproperRequest($this->team, ImproperRequestType::Timeout); + } + } catch (InvalidGameEventTransition $exception) { + $this->addError('timeout', $exception->getMessage()); + + return; + } + + $this->dispatch('game-event-recorded'); + $this->dispatch( + 'timeout-recorded', + team: $this->team->value, + hasTimeoutLeft: $hasTimeoutLeft, + improperRequest: ! $hasTimeoutLeft, + delayWarning: $recordedRequestType === GameEventType::DelayWarningRecorded, + delayPenalty: $recordedRequestType === GameEventType::DelayPenaltyRecorded, + ); + } + + public function submitSubstitution(): void + { + $this->resetValidation('substitution'); + + $playerOut = (int) $this->playerOut; + $playerIn = (int) $this->playerIn; + + if ($playerOut === 0 || $playerIn === 0) { + $this->addError('substitution', 'Both player numbers are required.'); + + return; + } + + $constraints = $this->substitutionConstraints(); + + if ($constraints['count'] >= 6) { + $this->addError('substitution', 'This team has used all 6 substitutions for this set.'); + + return; + } + + if (in_array($playerOut, $constraints['locked'], true)) { + $this->addError('substitution', "Player $playerOut cannot be substituted again this set."); + + return; + } + + if (in_array($playerIn, $constraints['locked'], true)) { + $this->addError('substitution', "Player $playerIn cannot be substituted again this set."); + + return; + } + + if (isset($constraints['partnerFor'][$playerOut]) && $constraints['partnerFor'][$playerOut] !== $playerIn) { + $this->addError('substitution', "Player $playerOut can only be replaced by player {$constraints['partnerFor'][$playerOut]}."); + + return; + } + + if (isset($constraints['partnerFor'][$playerIn]) && $constraints['partnerFor'][$playerIn] !== $playerOut) { + $this->addError('substitution', "Player $playerIn can only return by replacing player {$constraints['partnerFor'][$playerIn]}."); + + return; + } + + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->addError('substitution', 'No active game is available to record the substitution.'); + + return; + } + + try { + $activeGame->recordSubstitution($this->team, $playerOut, $playerIn); + } catch (InvalidGameEventTransition $exception) { + $this->addError('substitution', $exception->getMessage()); + + return; + } + + $this->playerOut = ''; + $this->playerIn = ''; + $this->dispatch('game-event-recorded'); + $this->dispatch('substitution-recorded', team: $this->team->value); + } + + public function requestSubstitutionWhenFull(): void + { + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->addError('substitution', 'No active game is available to record the substitution.'); + + return; + } + + if ($this->substitutionConstraints()['count'] < 6) { + $this->addError('substitution', 'This team still has substitutions available.'); + + return; + } + + try { + $recordedRequestType = $activeGame->recordImproperRequest($this->team, ImproperRequestType::Substitution); + } catch (InvalidGameEventTransition $exception) { + $this->addError('substitution', $exception->getMessage()); + + return; + } + + $this->dispatch('game-event-recorded'); + $this->dispatch( + 'substitution-improper-request-recorded', + team: $this->team->value, + delayWarning: $recordedRequestType === GameEventType::DelayWarningRecorded, + delayPenalty: $recordedRequestType === GameEventType::DelayPenaltyRecorded, + ); + } + + /** + * Returns substitution constraints for the current set. + * + * @return array{count: int, locked: array, partnerFor: array} + */ + #[Computed] + public function substitutionConstraints(): array + { + $game = $this->activeGame(); + + if ($game === null) { + return ['count' => 0, 'locked' => [], 'partnerFor' => []]; + } + + $lastSetStarted = GameEvent::query() + ->where('game_id', $game->getKey()) + ->where('type', GameEventType::SetStarted) + ->latest('id') + ->first(); + + $query = GameEvent::query() + ->where('game_id', $game->getKey()) + ->where('type', GameEventType::SubstitutionCompleted); + + if ($lastSetStarted !== null) { + $query->where('id', '>', $lastSetStarted->getKey()); + } + + $substitutions = $query->orderBy('id')->get(); + + /** @var array $pairs */ + $pairs = []; + $locked = []; + $count = 0; + + foreach ($substitutions as $event) { + /** @var SubstitutionCompletedPayload $payload */ + $payload = $event->payload; + + if ($payload->team !== $this->team) { + continue; + } + + $count++; + $pairKey = implode('-', [min($payload->playerOut, $payload->playerIn), max($payload->playerOut, $payload->playerIn)]); + + if (isset($pairs[$pairKey]) && ! $pairs[$pairKey]['reversed']) { + $pairs[$pairKey]['reversed'] = true; + $locked[] = $payload->playerOut; + $locked[] = $payload->playerIn; + } elseif (! isset($pairs[$pairKey])) { + $pairs[$pairKey] = ['out' => $payload->playerOut, 'in' => $payload->playerIn, 'reversed' => false]; + } + } + + $partnerFor = []; + + foreach ($pairs as $pair) { + if (! $pair['reversed']) { + $partnerFor[$pair['out']] = $pair['in']; + $partnerFor[$pair['in']] = $pair['out']; + } + } + + return ['count' => $count, 'locked' => array_values(array_unique($locked)), 'partnerFor' => $partnerFor]; + } + + public function render(): View + { + $teamPlayers = $this->teamPlayers(); + $rosterPlayerCount = count($teamPlayers); + $benchPlayers = $this->benchPlayers($teamPlayers); + $lineupSubmitted = $this->hasLineupBeenSubmitted($this->team); + $placeholderCount = $lineupSubmitted ? 0 : max(0, $rosterPlayerCount - 6); + + $onCourtNumbers = $this->onCourtRosterNumbers($this->team); + sort($onCourtNumbers); + + $benchNumbers = array_column($benchPlayers, 'number'); + sort($benchNumbers); + + $constraints = $this->substitutionConstraints(); + + return view('livewire.team-roster', [ + 'players' => $benchPlayers, + 'showPlayerPlaceholders' => $placeholderCount > 0, + 'placeholderCount' => $placeholderCount, + 'hasRosterPlayers' => $rosterPlayerCount > 0, + 'staffMarkers' => $this->buildStaffMarkers($this->teamStaff()), + 'reverseLayout' => $this->leftSide, + 'keyPrefix' => $this->leftSide ? 'left-player' : 'right-player', + 'markerTone' => $this->team === TeamAB::TeamA ? 'bg-blue-600' : 'bg-red-600', + 'timeoutsTaken' => $this->timeoutsTaken(), + 'substitutionsTaken' => $this->substitutionsTaken(), + 'canRequestTimeout' => $this->canRequestTimeout(), + 'timeoutDuration' => config('game.timeout_duration'), + 'canRequestSubstitution' => $this->canRequestSubstitution(), + 'canShowSubstitutionFullModal' => $this->canShowSubstitutionFullModal(), + 'onCourtNumbers' => $onCourtNumbers, + 'benchNumbers' => $benchNumbers, + 'lockedNumbers' => $constraints['locked'], + 'partnerFor' => $constraints['partnerFor'], + ]); + } + + #[Computed] + public function activeGame(): ?Game + { + return Game::query()->find($this->gameId); + } + + /** + * @return array + */ + #[Computed] + public function teamPlayers(): array + { + $game = $this->activeGame(); + + if ($game === null) { + return []; + } + + return $this->scoresheetDataRepository()->playersForSide($game, $this->targetSideForTeam($game, $this->team)); + } + + /** + * @param array $players + * @return array + */ + private function benchPlayers(array $players): array + { + $onCourtNumbers = $this->onCourtRosterNumbers($this->team); + + if ($onCourtNumbers === []) { + return []; + } + + return array_values(array_filter($players, fn (array $player): bool => ! in_array($player['number'], $onCourtNumbers, true))); + } + + /** + * @return array + */ + #[Computed] + public function teamStaff(): array + { + $game = $this->activeGame(); + + if ($game === null) { + return []; + } + + return $this->scoresheetDataRepository()->staffForSide($game, $this->targetSideForTeam($game, $this->team)); + } + + /** + * @param array $staff + * @return array + */ + private function buildStaffMarkers(array $staff): array + { + if ($staff === []) { + return []; + } + + $assistantCoaches = 0; + $hasCoach = false; + $hasDoctor = false; + $hasTherapist = false; + + foreach ($staff as $staffMember) { + match ($staffMember['role']) { + StaffRole::Coach => $hasCoach = true, + StaffRole::AssistantCoach => $assistantCoaches++, + StaffRole::Doctor => $hasDoctor = true, + StaffRole::Therapist => $hasTherapist = true, + }; + } + + $markers = []; + + if ($hasCoach) { + $markers[] = ['role_letter' => 'C', 'subscript' => null]; + } + + if ($assistantCoaches >= 1) { + $markers[] = ['role_letter' => 'A', 'subscript' => 1]; + } + + if ($assistantCoaches >= 2) { + $markers[] = ['role_letter' => 'A', 'subscript' => 2]; + } + + if ($hasDoctor) { + $markers[] = ['role_letter' => 'D', 'subscript' => null]; + } + + if ($hasTherapist) { + $markers[] = ['role_letter' => 'T', 'subscript' => null]; + } + + return $markers; + } + + private function scoresheetDataRepository(): ScoresheetDataRepository + { + return app(ScoresheetDataRepository::class); + } + + private function targetSideForTeam(Game $game, TeamAB $team): TeamSide + { + return $this->gameSideResolver()->sideForTeam($game, $team); + } + + /** + * @return array + */ + private function onCourtRosterNumbers(TeamAB $team): array + { + $state = $this->gameState ?? GameState::initial(); + + return $team === TeamAB::TeamA + ? array_values($state->rotationTeamA) + : array_values($state->rotationTeamB); + } + + private function hasLineupBeenSubmitted(TeamAB $team): bool + { + return $this->onCourtRosterNumbers($team) !== []; + } + + private function canRequestSubstitution(): bool + { + $state = $this->gameState ?? GameState::initial(); + + return $state->setInProgress && ! $state->gameEnded && $this->substitutionsTaken() < 6; + } + + private function canShowSubstitutionFullModal(): bool + { + $state = $this->gameState ?? GameState::initial(); + + return $state->setInProgress && ! $state->gameEnded && $this->substitutionsTaken() >= 6; + } + + private function canRequestTimeout(): bool + { + $state = $this->gameState ?? GameState::initial(); + + return $state->setInProgress && ! $state->gameEnded; + } + + private function timeoutsTaken(): int + { + $state = $this->gameState ?? GameState::initial(); + + return $this->team === TeamAB::TeamA ? $state->timeoutsTeamA : $state->timeoutsTeamB; + } + + private function substitutionsTaken(): int + { + $state = $this->gameState ?? GameState::initial(); + + return $this->team === TeamAB::TeamA ? $state->substitutionsTeamA : $state->substitutionsTeamB; + } + + private function gameSideResolver(): GameSideResolver + { + return app(GameSideResolver::class); + } +} diff --git a/app/Livewire/TossResultSubmission.php b/app/Livewire/TossResultSubmission.php new file mode 100644 index 0000000..16fbb27 --- /dev/null +++ b/app/Livewire/TossResultSubmission.php @@ -0,0 +1,210 @@ +value; + + public string $left = TeamSide::Home->value; + + public string $serving = TeamSide::Home->value; + + public function mount(?int $gameId = null): void + { + $this->gameId = $gameId; + } + + /** + * @return array> + */ + protected function rules(): array + { + $sideField = $this->isFifthSetToss() ? 'left' : 'teamA'; + + return [ + $sideField => ['required', Rule::enum(TeamSide::class)], + 'serving' => ['required', Rule::enum(TeamSide::class)], + ]; + } + + /** + * @return array + */ + protected function messages(): array + { + return [ + 'teamA.required' => 'Select whether Team A is home or away.', + 'left.required' => 'Select which team will start on the left.', + 'serving.required' => 'Select whether the home or away team serves first.', + ]; + } + + public function submit(): void + { + if (! $this->isFifthSetToss() && ! $this->hasSubmittedRosters()) { + $this->addError('submit', 'Both team rosters must be submitted before the toss.'); + + return; + } + + $this->validate(); + + if ($this->gameId === null) { + $this->addError('submit', 'No active game is available to record the toss.'); + + return; + } + + $activeGame = Game::query()->whereKey($this->gameId)->first(); + + if ($activeGame === null) { + $this->addError('submit', 'No active game is available to record the toss.'); + + return; + } + + try { + if ($this->isFifthSetToss()) { + $teamASide = $this->resolvedGameState($activeGame)->teamASide; + + if ($teamASide === null) { + $this->addError('submit', 'The initial toss must be recorded before the fifth set toss.'); + + return; + } + + $leftSide = TeamSide::from($this->left); + $servingSide = TeamSide::from($this->serving); + + $activeGame->recordToss( + teamA: $teamASide, + serving: $this->teamForSide($teamASide, $servingSide), + leftTeam: $this->teamForSide($teamASide, $leftSide), + ); + } else { + $teamASide = TeamSide::from($this->teamA); + $servingSide = TeamSide::from($this->serving); + + $activeGame->recordToss( + teamA: $teamASide, + serving: $this->teamForSide($teamASide, $servingSide), + ); + } + } catch (InvalidGameEventTransition $exception) { + $this->addError('submit', $exception->getMessage()); + + return; + } + + Flux::modal('submit-toss-result')->close(); + $this->dispatch('game-event-recorded'); + + $this->resetValidation(); + $this->teamA = TeamSide::Home->value; + $this->left = TeamSide::Home->value; + $this->serving = TeamSide::Home->value; + } + + public function render(): View + { + $activeGame = $this->activeGame(); + + return view('livewire.toss-result-submission', [ + 'hasSubmittedToss' => $this->hasSubmittedToss($activeGame), + 'hasSubmittedRosters' => $this->hasSubmittedRosters($activeGame), + 'isBeforeInitialToss' => $this->isBeforeInitialToss($activeGame), + 'isFifthSetToss' => $this->isFifthSetToss(), + 'homeTeamCode' => $activeGame?->homeTeam->country_code ?? 'Home Team', + 'awayTeamCode' => $activeGame?->awayTeam->country_code ?? 'Away Team', + ]); + } + + private function hasSubmittedToss(?Game $activeGame = null): bool + { + $state = $this->resolvedGameState($activeGame); + + return $this->gameSideResolver()->hasRequiredToss($state); + } + + private function resolvedGameState(?Game $activeGame = null): GameState + { + if ($activeGame !== null) { + return $activeGame->stateAt(); + } + + if ($this->gameId === null) { + return $this->gameState ?? GameState::initial(); + } + + $activeGame = Game::query()->whereKey($this->gameId)->first(); + + return $activeGame?->stateAt() ?? ($this->gameState ?? GameState::initial()); + } + + private function activeGame(): ?Game + { + if ($this->gameId === null) { + return null; + } + + return Game::query() + ->with(['homeTeam', 'awayTeam']) + ->whereKey($this->gameId) + ->first(); + } + + private function isFifthSetToss(): bool + { + return $this->gameSideResolver()->requiresFifthSetToss($this->resolvedGameState()); + } + + private function isBeforeInitialToss(?Game $activeGame = null): bool + { + $state = $this->resolvedGameState($activeGame); + + return ! $this->gameSideResolver()->hasRequiredToss($state) + && ! $this->gameSideResolver()->requiresFifthSetToss($state); + } + + private function hasSubmittedRosters(?Game $activeGame = null): bool + { + $game = $activeGame ?? $this->activeGame(); + + return $game?->hasSubmittedInitialRosters() ?? false; + } + + private function teamForSide(TeamSide $teamASide, TeamSide $selectedSide): TeamAB + { + return $selectedSide === $teamASide + ? TeamAB::TeamA + : TeamAB::TeamB; + } + + private function gameSideResolver(): GameSideResolver + { + return app(GameSideResolver::class); + } +} diff --git a/app/Models/Concerns/RecordsCourtSideSwap.php b/app/Models/Concerns/RecordsCourtSideSwap.php new file mode 100644 index 0000000..1123147 --- /dev/null +++ b/app/Models/Concerns/RecordsCourtSideSwap.php @@ -0,0 +1,26 @@ +assertCanRecordCourtSidesSwapped($this); + + $this->events()->create([ + 'type' => GameEventType::CourtSidesSwapped, + 'payload' => new CourtSidesSwappedPayload, + ]); + } +} diff --git a/app/Models/Concerns/RecordsEndOfGame.php b/app/Models/Concerns/RecordsEndOfGame.php index c6615c8..a7f42df 100644 --- a/app/Models/Concerns/RecordsEndOfGame.php +++ b/app/Models/Concerns/RecordsEndOfGame.php @@ -6,10 +6,11 @@ use App\Enums\GameEventType; use App\Events\Payloads\GameEndedPayload; +use App\Models\Game; use App\Services\GameState\GameEventRuleValidator; /** - * @mixin \App\Models\Game + * @mixin Game */ trait RecordsEndOfGame { diff --git a/app/Models/Concerns/RecordsEndOfRally.php b/app/Models/Concerns/RecordsEndOfRally.php index 2a5624f..258d996 100644 --- a/app/Models/Concerns/RecordsEndOfRally.php +++ b/app/Models/Concerns/RecordsEndOfRally.php @@ -7,10 +7,11 @@ use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Events\Payloads\RallyEndedPayload; +use App\Models\Game; use App\Services\GameState\GameEventRuleValidator; /** - * @mixin \App\Models\Game + * @mixin Game */ trait RecordsEndOfRally { diff --git a/app/Models/Concerns/RecordsEndOfSet.php b/app/Models/Concerns/RecordsEndOfSet.php index b0228ec..99e2ad6 100644 --- a/app/Models/Concerns/RecordsEndOfSet.php +++ b/app/Models/Concerns/RecordsEndOfSet.php @@ -6,10 +6,11 @@ use App\Enums\GameEventType; use App\Events\Payloads\SetEndedPayload; +use App\Models\Game; use App\Services\GameState\GameEventRuleValidator; /** - * @mixin \App\Models\Game + * @mixin Game */ trait RecordsEndOfSet { diff --git a/app/Models/Concerns/RecordsImproperRequest.php b/app/Models/Concerns/RecordsImproperRequest.php new file mode 100644 index 0000000..d8e7ea2 --- /dev/null +++ b/app/Models/Concerns/RecordsImproperRequest.php @@ -0,0 +1,112 @@ +assertCanRecordImproperRequest($this); + + if ($this->hasDelayWarningFor($team)) { + $this->events()->create([ + 'type' => GameEventType::DelayPenaltyRecorded, + 'payload' => new DelayPenaltyRecordedPayload( + team: $team, + awardedTeam: $this->opponentOf($team), + requestType: $requestType, + ), + ]); + + return GameEventType::DelayPenaltyRecorded; + } + + if ($this->hasImproperRequestFor($team)) { + $this->events()->create([ + 'type' => GameEventType::DelayWarningRecorded, + 'payload' => new DelayWarningRecordedPayload( + team: $team, + requestType: $requestType, + ), + ]); + + return GameEventType::DelayWarningRecorded; + } + + $this->events()->create([ + 'type' => GameEventType::ImproperRequestRecorded, + 'payload' => new ImproperRequestRecordedPayload( + team: $team, + requestType: $requestType, + ), + ]); + + return GameEventType::ImproperRequestRecorded; + } + + public function recordDelayWarning(TeamAB $team): void + { + app(GameEventRuleValidator::class)->assertCanRecordDelayWarning($this, $team); + + $this->events()->create([ + 'type' => GameEventType::DelayWarningRecorded, + 'payload' => new DelayWarningRecordedPayload( + team: $team, + ), + ]); + } + + public function recordDelayPenalty(TeamAB $team): void + { + app(GameEventRuleValidator::class)->assertCanRecordDelayPenalty($this); + + $this->events()->create([ + 'type' => GameEventType::DelayPenaltyRecorded, + 'payload' => new DelayPenaltyRecordedPayload( + team: $team, + awardedTeam: $this->opponentOf($team), + ), + ]); + } + + private function hasDelayWarningFor(TeamAB $team): bool + { + $state = $this->stateAt(); + + if ($team === TeamAB::TeamA) { + return $state->delayWarningsTeamA + $state->delayPenaltiesTeamA > 0; + } + + return $state->delayWarningsTeamB + $state->delayPenaltiesTeamB > 0; + } + + private function hasImproperRequestFor(TeamAB $team): bool + { + $state = $this->stateAt(); + + if ($team === TeamAB::TeamA) { + return $state->improperRequestsTeamA + $state->delayWarningsTeamA > 0; + } + + return $state->improperRequestsTeamB + $state->delayWarningsTeamB > 0; + } + + private function opponentOf(TeamAB $team): TeamAB + { + return $team === TeamAB::TeamA ? TeamAB::TeamB : TeamAB::TeamA; + } +} diff --git a/app/Models/Concerns/RecordsLineup.php b/app/Models/Concerns/RecordsLineup.php index f8dde7a..a74e187 100644 --- a/app/Models/Concerns/RecordsLineup.php +++ b/app/Models/Concerns/RecordsLineup.php @@ -9,13 +9,14 @@ use App\Enums\TeamSide; use App\Events\Payloads\LineupSubmittedPayload; use App\Events\Payloads\TossCompletedPayload; +use App\Models\Game; use App\Models\GameEvent; use App\Services\GameState\GameEventRuleValidator; use Illuminate\Support\Collection; use LogicException; /** - * @mixin \App\Models\Game + * @mixin Game */ trait RecordsLineup { @@ -25,7 +26,7 @@ public function recordLineup(int $set, TeamAB $team, array $positions): void app(GameEventRuleValidator::class)->assertCanRecordLineup($this, $set); $tossPayload = $this->getLatestTossPayload(); - $validPlayerIds = $this->resolveRosterForTeam($team, $tossPayload); + $validRosterNumbers = $this->resolveRosterNumbersForTeam($team, $tossPayload); $this->events()->create([ 'type' => GameEventType::LineupSubmitted, @@ -33,7 +34,7 @@ public function recordLineup(int $set, TeamAB $team, array $positions): void set: $set, team: $team, positions: $positions, - validPlayerIds: $validPlayerIds, + validRosterNumbers: $validRosterNumbers, ), ]); } @@ -57,14 +58,14 @@ private function getLatestTossPayload(): TossCompletedPayload /** * @return Collection */ - private function resolveRosterForTeam(TeamAB $team, TossCompletedPayload $tossPayload): Collection + private function resolveRosterNumbersForTeam(TeamAB $team, TossCompletedPayload $tossPayload): Collection { $side = $team === TeamAB::TeamA ? $tossPayload->teamA : ($tossPayload->teamA === TeamSide::Home ? TeamSide::Away : TeamSide::Home); return $side === TeamSide::Home - ? $this->homePlayers()->pluck('players.id') - : $this->awayPlayers()->pluck('players.id'); + ? $this->homePlayers()->wherePivot('is_libero', false)->pluck('game_player.number') + : $this->awayPlayers()->wherePivot('is_libero', false)->pluck('game_player.number'); } } diff --git a/app/Models/Concerns/RecordsMisconduct.php b/app/Models/Concerns/RecordsMisconduct.php new file mode 100644 index 0000000..a74af26 --- /dev/null +++ b/app/Models/Concerns/RecordsMisconduct.php @@ -0,0 +1,38 @@ +assertCanRecordMisconduct($this, $team, $subjectType, $subjectId, $sanction); + + $this->events()->create([ + 'type' => GameEventType::MisconductRecorded, + 'payload' => new MisconductRecordedPayload( + team: $team, + subjectType: $subjectType, + subjectId: $subjectId, + sanction: $sanction, + ), + ]); + } +} diff --git a/app/Models/Concerns/RecordsStartOfSet.php b/app/Models/Concerns/RecordsStartOfSet.php index cd5b969..e27c641 100644 --- a/app/Models/Concerns/RecordsStartOfSet.php +++ b/app/Models/Concerns/RecordsStartOfSet.php @@ -6,10 +6,11 @@ use App\Enums\GameEventType; use App\Events\Payloads\SetStartedPayload; +use App\Models\Game; use App\Services\GameState\GameEventRuleValidator; /** - * @mixin \App\Models\Game + * @mixin Game */ trait RecordsStartOfSet { diff --git a/app/Models/Concerns/RecordsSubstitution.php b/app/Models/Concerns/RecordsSubstitution.php index c652e0f..5eb53ec 100644 --- a/app/Models/Concerns/RecordsSubstitution.php +++ b/app/Models/Concerns/RecordsSubstitution.php @@ -7,10 +7,11 @@ use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Events\Payloads\SubstitutionCompletedPayload; +use App\Models\Game; use App\Services\GameState\GameEventRuleValidator; /** - * @mixin \App\Models\Game + * @mixin Game */ trait RecordsSubstitution { diff --git a/app/Models/Concerns/RecordsTimeOut.php b/app/Models/Concerns/RecordsTimeOut.php index 3dd6e4d..8764453 100644 --- a/app/Models/Concerns/RecordsTimeOut.php +++ b/app/Models/Concerns/RecordsTimeOut.php @@ -7,10 +7,11 @@ use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Events\Payloads\TimeOutRequestedPayload; +use App\Models\Game; use App\Services\GameState\GameEventRuleValidator; /** - * @mixin \App\Models\Game + * @mixin Game */ trait RecordsTimeOut { diff --git a/app/Models/Concerns/RecordsToss.php b/app/Models/Concerns/RecordsToss.php index 0a044f7..72a8a81 100644 --- a/app/Models/Concerns/RecordsToss.php +++ b/app/Models/Concerns/RecordsToss.php @@ -8,14 +8,15 @@ use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Events\Payloads\TossCompletedPayload; +use App\Models\Game; use App\Services\GameState\GameEventRuleValidator; /** - * @mixin \App\Models\Game + * @mixin Game */ trait RecordsToss { - public function recordToss(TeamSide $teamA, TeamAB $serving): void + public function recordToss(TeamSide $teamA, TeamAB $serving, ?TeamAB $leftTeam = null): void { app(GameEventRuleValidator::class)->assertCanRecordToss($this); @@ -24,6 +25,7 @@ public function recordToss(TeamSide $teamA, TeamAB $serving): void 'payload' => new TossCompletedPayload( teamA: $teamA, serving: $serving, + leftTeam: $leftTeam ?? TeamAB::TeamA, ), ]); } diff --git a/app/Models/Game.php b/app/Models/Game.php index 1466af2..a6cefcc 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -7,10 +7,14 @@ use App\Data\GameState\GameState; use App\Enums\OfficialRole; use App\Enums\StaffRole; +use App\Enums\TeamAB; +use App\Models\Concerns\RecordsCourtSideSwap; use App\Models\Concerns\RecordsEndOfGame; use App\Models\Concerns\RecordsEndOfRally; use App\Models\Concerns\RecordsEndOfSet; +use App\Models\Concerns\RecordsImproperRequest; use App\Models\Concerns\RecordsLineup; +use App\Models\Concerns\RecordsMisconduct; use App\Models\Concerns\RecordsStartOfSet; use App\Models\Concerns\RecordsSubstitution; use App\Models\Concerns\RecordsTimeOut; @@ -23,6 +27,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Facades\DB; /** * @property int $championship_id @@ -36,6 +41,7 @@ * @property string $division * @property string $pool * @property string $category + * @property bool $rosters_submitted * @property CarbonImmutable|null $created_at * @property CarbonImmutable|null $updated_at * @property-read Championship $championship @@ -56,10 +62,13 @@ class Game extends Model /** @use HasFactory */ use HasFactory; + use RecordsCourtSideSwap; use RecordsEndOfGame; use RecordsEndOfRally; use RecordsEndOfSet; + use RecordsImproperRequest; use RecordsLineup; + use RecordsMisconduct; use RecordsStartOfSet; use RecordsSubstitution; use RecordsTimeOut; @@ -73,6 +82,7 @@ protected function casts(): array { return [ 'number' => 'integer', + 'rosters_submitted' => 'boolean', 'date_time' => 'immutable_datetime', 'created_at' => 'immutable_datetime', 'updated_at' => 'immutable_datetime', @@ -187,14 +197,66 @@ public function addPlayer(Player $player, int $number, bool $isCaptain = false, /** * Add a staff member to the match roster. */ - public function addStaff(Staff $staff, StaffRole|string $role): void + public function addStaff(Staff $staff, StaffRole|string|null $role = null): void { + $resolvedRole = $role instanceof StaffRole + ? $role + : ($role !== null ? StaffRole::from($role) : $staff->role); + $this->staff()->attach($staff, [ 'team_id' => $staff->team_id, - 'role' => $role, + 'role' => $resolvedRole, ]); } + /** + * @param array $players + * @param array $staff + */ + public function replaceRosterForTeam(TeamAB $team, array $players, array $staff): void + { + $teamId = $team === TeamAB::TeamA ? $this->home_team_id : $this->away_team_id; + + DB::transaction(function () use ($players, $staff, $teamId): void { + RosterPlayer::query() + ->where('game_id', $this->getKey()) + ->where('team_id', $teamId) + ->delete(); + + RosterStaff::query() + ->where('game_id', $this->getKey()) + ->where('team_id', $teamId) + ->delete(); + + foreach ($players as $playerRoster) { + $this->addPlayer( + player: $playerRoster['player'], + number: $playerRoster['number'], + isCaptain: $playerRoster['is_captain'], + isLibero: $playerRoster['is_libero'], + ); + } + + foreach ($staff as $staffMember) { + $this->addStaff($staffMember); + } + }); + } + + public function hasSubmittedInitialRosters(): bool + { + return $this->rosters_submitted + && $this->homePlayers()->exists() + && $this->awayPlayers()->exists(); + } + + public function markRostersSubmitted(): void + { + $this->forceFill([ + 'rosters_submitted' => true, + ])->save(); + } + /** * Add an official to the match. */ diff --git a/app/Models/GameEvent.php b/app/Models/GameEvent.php index 7b71031..b4d2ea6 100644 --- a/app/Models/GameEvent.php +++ b/app/Models/GameEvent.php @@ -5,9 +5,14 @@ namespace App\Models; use App\Enums\GameEventType; +use App\Events\Payloads\CourtSidesSwappedPayload; +use App\Events\Payloads\DelayPenaltyRecordedPayload; +use App\Events\Payloads\DelayWarningRecordedPayload; use App\Events\Payloads\GameEndedPayload; use App\Events\Payloads\GameEventPayload; +use App\Events\Payloads\ImproperRequestRecordedPayload; use App\Events\Payloads\LineupSubmittedPayload; +use App\Events\Payloads\MisconductRecordedPayload; use App\Events\Payloads\RallyEndedPayload; use App\Events\Payloads\SetEndedPayload; use App\Events\Payloads\SetStartedPayload; @@ -64,8 +69,13 @@ public function payload(): Attribute GameEventType::TossCompleted => TossCompletedPayload::fromArray($data), GameEventType::LineupSubmitted => LineupSubmittedPayload::fromArray($data), GameEventType::RallyEnded => RallyEndedPayload::fromArray($data), + GameEventType::CourtSidesSwapped => CourtSidesSwappedPayload::fromArray($data), GameEventType::SubstitutionCompleted => SubstitutionCompletedPayload::fromArray($data), GameEventType::TimeOutRequested => TimeOutRequestedPayload::fromArray($data), + GameEventType::ImproperRequestRecorded => ImproperRequestRecordedPayload::fromArray($data), + GameEventType::DelayWarningRecorded => DelayWarningRecordedPayload::fromArray($data), + GameEventType::DelayPenaltyRecorded => DelayPenaltyRecordedPayload::fromArray($data), + GameEventType::MisconductRecorded => MisconductRecordedPayload::fromArray($data), GameEventType::SetStarted => SetStartedPayload::fromArray($data), GameEventType::SetEnded => SetEndedPayload::fromArray($data), GameEventType::GameEnded => GameEndedPayload::fromArray($data), diff --git a/app/Models/GameStateSnapshot.php b/app/Models/GameStateSnapshot.php index 87a3ed3..c945ce4 100644 --- a/app/Models/GameStateSnapshot.php +++ b/app/Models/GameStateSnapshot.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Enums\TeamAB; +use App\Enums\TeamSide; use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -21,6 +22,23 @@ * @property int $timeouts_team_b * @property int $substitutions_team_a * @property int $substitutions_team_b + * @property int $improper_requests_team_a + * @property int $improper_requests_team_b + * @property int $delay_warnings_team_a + * @property int $delay_warnings_team_b + * @property int $delay_penalties_team_a + * @property int $delay_penalties_team_b + * @property int $misconduct_warnings_team_a + * @property int $misconduct_warnings_team_b + * @property int $misconduct_penalties_team_a + * @property int $misconduct_penalties_team_b + * @property int $misconduct_expulsions_team_a + * @property int $misconduct_expulsions_team_b + * @property int $misconduct_disqualifications_team_a + * @property int $misconduct_disqualifications_team_b + * @property TeamSide|null $team_a_side + * @property TeamAB|null $fifth_set_left_team + * @property bool $fifth_set_side_swapped * @property TeamAB|null $serving_team * @property array $rotation_team_a * @property array $rotation_team_b @@ -50,6 +68,23 @@ protected function casts(): array 'timeouts_team_b' => 'integer', 'substitutions_team_a' => 'integer', 'substitutions_team_b' => 'integer', + 'improper_requests_team_a' => 'integer', + 'improper_requests_team_b' => 'integer', + 'delay_warnings_team_a' => 'integer', + 'delay_warnings_team_b' => 'integer', + 'delay_penalties_team_a' => 'integer', + 'delay_penalties_team_b' => 'integer', + 'misconduct_warnings_team_a' => 'integer', + 'misconduct_warnings_team_b' => 'integer', + 'misconduct_penalties_team_a' => 'integer', + 'misconduct_penalties_team_b' => 'integer', + 'misconduct_expulsions_team_a' => 'integer', + 'misconduct_expulsions_team_b' => 'integer', + 'misconduct_disqualifications_team_a' => 'integer', + 'misconduct_disqualifications_team_b' => 'integer', + 'team_a_side' => TeamSide::class, + 'fifth_set_left_team' => TeamAB::class, + 'fifth_set_side_swapped' => 'boolean', 'serving_team' => TeamAB::class, 'rotation_team_a' => 'array', 'rotation_team_b' => 'array', diff --git a/app/Models/RosterPlayer.php b/app/Models/RosterPlayer.php index 75bd025..e832bbf 100644 --- a/app/Models/RosterPlayer.php +++ b/app/Models/RosterPlayer.php @@ -5,6 +5,7 @@ namespace App\Models; use Carbon\CarbonImmutable; +use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Relations\Pivot; /** @@ -17,6 +18,7 @@ * @property CarbonImmutable|null $created_at * @property CarbonImmutable|null $updated_at */ +#[Table(name: 'game_player')] class RosterPlayer extends Pivot { #[\Override] diff --git a/app/Models/RosterStaff.php b/app/Models/RosterStaff.php index 76fb152..2c44bf9 100644 --- a/app/Models/RosterStaff.php +++ b/app/Models/RosterStaff.php @@ -6,6 +6,7 @@ use App\Enums\StaffRole; use Carbon\CarbonImmutable; +use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Relations\Pivot; /** @@ -16,6 +17,7 @@ * @property CarbonImmutable|null $created_at * @property CarbonImmutable|null $updated_at */ +#[Table(name: 'game_staff')] class RosterStaff extends Pivot { #[\Override] diff --git a/app/Models/Staff.php b/app/Models/Staff.php index d7ea2a4..2a8e663 100644 --- a/app/Models/Staff.php +++ b/app/Models/Staff.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Enums\StaffRole; use Carbon\CarbonImmutable; use Database\Factories\StaffFactory; use Illuminate\Database\Eloquent\Collection as EloquentCollection; @@ -16,6 +17,7 @@ * @property int $team_id * @property string $first_name * @property string $last_name + * @property StaffRole $role * @property CarbonImmutable|null $created_at * @property CarbonImmutable|null $updated_at * @property-read Team $team @@ -31,6 +33,7 @@ class Staff extends Model protected function casts(): array { return [ + 'role' => StaffRole::class, 'created_at' => 'immutable_datetime', 'updated_at' => 'immutable_datetime', ]; diff --git a/app/Models/User.php b/app/Models/User.php index 0322b84..2377c89 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,38 +5,27 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use Database\Factories\UserFactory; +use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +#[Fillable([ + 'name', + 'email', + 'password', +])] +#[Hidden([ + 'password', + 'remember_token', +])] class User extends Authenticatable { - /** @use HasFactory<\Database\Factories\UserFactory> */ + /** @use HasFactory */ use HasFactory, Notifiable; - /** - * The attributes that are mass assignable. - * - * @var list - */ - #[\Override] - protected $fillable = [ - 'name', - 'email', - 'password', - ]; - - /** - * The attributes that should be hidden for serialization. - * - * @var list - */ - #[\Override] - protected $hidden = [ - 'password', - 'remember_token', - ]; - /** * Get the attributes that should be cast. * diff --git a/app/Services/GameSideResolver.php b/app/Services/GameSideResolver.php new file mode 100644 index 0000000..9ea7217 --- /dev/null +++ b/app/Services/GameSideResolver.php @@ -0,0 +1,123 @@ +stateAt()->teamASide !== null; + } + + public function teamASideForToss(Game $game): TeamSide + { + $teamASide = $game->stateAt()->teamASide; + + if ($teamASide === null) { + return TeamSide::Home; + } + + return $teamASide; + } + + public function sideForTeam(Game $game, TeamAB $team): TeamSide + { + return $this->sideForTeamFromTeamASide($this->teamASideForToss($game), $team); + } + + public function sideForTeamFromToss(Game $game, TeamAB $team): ?TeamSide + { + $teamASide = $game->stateAt()->teamASide; + + if ($teamASide === null) { + return null; + } + + return $this->sideForTeamFromTeamASide($teamASide, $team); + } + + public function teamOnLeft(int $completedSets): TeamAB + { + return $completedSets % 2 === 0 + ? TeamAB::TeamA + : TeamAB::TeamB; + } + + public function teamOnLeftForState(GameState $state): TeamAB + { + if (($this->requiresFifthSetToss($state) || $state->setNumber >= 5) && $state->fifthSetLeftTeam !== null) { + return $state->fifthSetLeftTeam; + } + + return $this->teamOnLeft($state->setsWonTeamA + $state->setsWonTeamB); + } + + public function teamOnRight(int $completedSets): TeamAB + { + return $this->oppositeTeam($this->teamOnLeft($completedSets)); + } + + public function teamOnRightForState(GameState $state): TeamAB + { + return $this->oppositeTeam($this->teamOnLeftForState($state)); + } + + public function oppositeTeam(TeamAB $team): TeamAB + { + return $team === TeamAB::TeamA + ? TeamAB::TeamB + : TeamAB::TeamA; + } + + public function oppositeSide(TeamSide $side): TeamSide + { + return $side === TeamSide::Home + ? TeamSide::Away + : TeamSide::Home; + } + + private function sideForTeamFromTeamASide(TeamSide $teamASide, TeamAB $team): TeamSide + { + return $team === TeamAB::TeamA + ? $teamASide + : $this->oppositeSide($teamASide); + } + + public function hasRequiredToss(GameState $state): bool + { + if ($state->teamASide === null || $state->servingTeam === null) { + return false; + } + + if (! $this->requiresFifthSetToss($state)) { + return true; + } + + return $state->fifthSetLeftTeam !== null; + } + + public function requiresFifthSetToss(GameState $state): bool + { + return ! $state->gameEnded + && ! $state->setInProgress + && $state->setNumber === 4 + && $state->setsWonTeamA === 2 + && $state->setsWonTeamB === 2; + } + + public function shouldPromptForFifthSetSideSwap(GameState $state): bool + { + return $state->setInProgress + && $state->setNumber === 5 + && ! $state->gameEnded + && ! $state->fifthSetSideSwapped + && max($state->scoreTeamA, $state->scoreTeamB) >= 8; + } +} diff --git a/app/Services/GameState/GameEventReactor.php b/app/Services/GameState/GameEventReactor.php index 051ad06..6fb0e53 100644 --- a/app/Services/GameState/GameEventReactor.php +++ b/app/Services/GameState/GameEventReactor.php @@ -5,23 +5,42 @@ namespace App\Services\GameState; use App\Enums\GameEventType; +use App\Enums\MisconductSanction; use App\Events\Payloads\GameEndedPayload; use App\Events\Payloads\GameEventPayload; +use App\Events\Payloads\MisconductRecordedPayload; use App\Events\Payloads\SetEndedPayload; use App\Models\GameEvent; class GameEventReactor { + public function __construct( + protected SetScoringRules $setScoringRules + ) {} + public function reactTo(GameEvent $event): void { match ($event->type) { - GameEventType::RallyEnded => $this->onRallyEnded($event), + GameEventType::RallyEnded, GameEventType::DelayPenaltyRecorded => $this->onRallyAwarded($event), + GameEventType::MisconductRecorded => $this->onMisconductRecorded($event), GameEventType::SetEnded => $this->onSetEnded($event), default => null, }; } - protected function onRallyEnded(GameEvent $event): void + protected function onMisconductRecorded(GameEvent $event): void + { + /** @var MisconductRecordedPayload $payload */ + $payload = $event->payload; + + if ($payload->sanction !== MisconductSanction::Penalty) { + return; + } + + $this->onRallyAwarded($event); + } + + protected function onRallyAwarded(GameEvent $event): void { $state = $event->game->stateAt(); @@ -29,11 +48,7 @@ protected function onRallyEnded(GameEvent $event): void return; } - $targetPoints = $state->setNumber === 5 ? 15 : 25; - $highestScore = max($state->scoreTeamA, $state->scoreTeamB); - $scoreDifference = abs($state->scoreTeamA - $state->scoreTeamB); - - if ($highestScore < $targetPoints || $scoreDifference < 2) { + if (! $this->setScoringRules->canEndSet($state->setNumber, $state->scoreTeamA, $state->scoreTeamB)) { return; } diff --git a/app/Services/GameState/GameEventRuleValidator.php b/app/Services/GameState/GameEventRuleValidator.php index 32d225e..13a4821 100644 --- a/app/Services/GameState/GameEventRuleValidator.php +++ b/app/Services/GameState/GameEventRuleValidator.php @@ -4,27 +4,45 @@ namespace App\Services\GameState; +use App\Data\GameState\GameState; use App\Enums\GameEventType; +use App\Enums\MisconductSanction; +use App\Enums\MisconductSubjectType; +use App\Enums\TeamAB; +use App\Events\Payloads\MisconductRecordedPayload; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; +use App\Models\GameEvent; +use Carbon\CarbonImmutable; class GameEventRuleValidator { + public function __construct( + protected SetScoringRules $setScoringRules + ) {} + public function assertCanRecordToss(Game $game): void { $state = $game->stateAt(); - if ($state->gameEnded || $state->setNumber > 0 || $this->hasRecordedToss($game)) { - $this->fail('The toss can only be recorded once before the first set starts.'); + if (! $this->hasRecordedToss($game)) { + if ($state->gameEnded || $state->setNumber > 0) { + $this->fail('The toss can only be recorded before the first set starts or before the fifth set.'); + } + + return; } + if (! $this->requiresFifthSetToss($state) || $state->fifthSetLeftTeam !== null) { + $this->fail('The toss can only be recorded before the first set starts or before the fifth set.'); + } } public function assertCanRecordSetStarted(Game $game): void { $state = $game->stateAt(); - if (! $this->hasRecordedToss($game)) { + if (! $this->hasRequiredToss($state)) { $this->fail('A set cannot start before the toss has been recorded.'); } @@ -35,22 +53,40 @@ public function assertCanRecordSetStarted(Game $game): void if ($state->setInProgress) { $this->fail('A set is already in progress.'); } + + if ($this->setBreakIsInProgress($game, $state)) { + $this->fail('The interval between sets has not elapsed yet.'); + } + + $upcomingSet = $state->setNumber + 1; + $hasTeamALineup = $this->hasSubmittedLineupForSet($game, TeamAB::TeamA, $upcomingSet); + $hasTeamBLineup = $this->hasSubmittedLineupForSet($game, TeamAB::TeamB, $upcomingSet); + + if (! $hasTeamALineup || ! $hasTeamBLineup) { + $this->fail('Both team lineups must be submitted before starting the set.'); + } } public function assertCanRecordLineup(Game $game, int $set): void { $state = $game->stateAt(); - if (! $this->hasRecordedToss($game)) { + if (! $this->hasRequiredToss($state)) { $this->fail('A lineup cannot be submitted before the toss has been recorded.'); } - if (! $state->setInProgress) { - $this->fail('A lineup can only be submitted during an active set.'); + if ($state->gameEnded || $state->setsWonTeamA >= 3 || $state->setsWonTeamB >= 3) { + $this->fail('A lineup cannot be submitted after the game has ended.'); + } + + if ($state->setInProgress) { + $this->fail('A lineup can only be submitted before the set starts.'); } - if ($set !== $state->setNumber) { - $this->fail('The lineup set number must match the current active set.'); + $expectedSet = $state->setNumber + 1; + + if ($set !== $expectedSet) { + $this->fail('The lineup set number must match the upcoming set.'); } } @@ -63,6 +99,27 @@ public function assertCanRecordRally(Game $game): void } } + public function assertCanRecordCourtSidesSwapped(Game $game): void + { + $state = $game->stateAt(); + + if ($state->gameEnded || ! $state->setInProgress || $state->setNumber !== 5) { + $this->fail('Court sides can only be swapped during the fifth set.'); + } + + if ($state->fifthSetLeftTeam === null) { + $this->fail('Court sides cannot be swapped before the fifth set toss has been recorded.'); + } + + if ($state->fifthSetSideSwapped) { + $this->fail('Court sides have already been swapped in the fifth set.'); + } + + if (max($state->scoreTeamA, $state->scoreTeamB) < 8) { + $this->fail('Court sides can only be swapped once a team reaches 8 points in the fifth set.'); + } + } + public function assertCanRecordSubstitution(Game $game): void { $state = $game->stateAt(); @@ -81,6 +138,95 @@ public function assertCanRecordTimeOut(Game $game): void } } + public function assertCanRecordImproperRequest(Game $game): void + { + $state = $game->stateAt(); + + if ($state->gameEnded || ! $state->setInProgress) { + $this->fail('An improper request can only be recorded while a set is in progress.'); + } + } + + public function assertCanRecordDelayWarning(Game $game, TeamAB $team): void + { + $state = $game->stateAt(); + + if ($state->gameEnded) { + $this->fail('A delay warning cannot be recorded after the game has ended.'); + } + + $hasDelaySanction = $team === TeamAB::TeamA + ? $state->delayWarningsTeamA + $state->delayPenaltiesTeamA > 0 + : $state->delayWarningsTeamB + $state->delayPenaltiesTeamB > 0; + + if ($hasDelaySanction) { + $this->fail('This team already has a delay warning.'); + } + } + + public function assertCanRecordDelayPenalty(Game $game): void + { + $state = $game->stateAt(); + + if ($state->gameEnded) { + $this->fail('A delay penalty cannot be recorded after the game has ended.'); + } + } + + public function assertCanRecordMisconduct( + Game $game, + TeamAB $team, + MisconductSubjectType $subjectType, + int $subjectId, + MisconductSanction $sanction, + ): void { + $state = $game->stateAt(); + + if ($state->gameEnded) { + $this->fail('Misconduct cannot be recorded after the game has ended.'); + } + + $hasMinorMisconduct = $team === TeamAB::TeamA + ? $state->misconductWarningsTeamA > 0 + : $state->misconductWarningsTeamB > 0; + + if ($sanction === MisconductSanction::Warning && $hasMinorMisconduct) { + $this->fail('This team already has a minor misconduct warning.'); + } + + $teamId = $team === TeamAB::TeamA ? $game->home_team_id : $game->away_team_id; + + $isRostered = match ($subjectType) { + MisconductSubjectType::Player => $game->players() + ->whereKey($subjectId) + ->wherePivot('team_id', $teamId) + ->exists(), + MisconductSubjectType::Staff => $game->staff() + ->whereKey($subjectId) + ->wherePivot('team_id', $teamId) + ->exists(), + }; + + if (! $isRostered) { + $this->fail('Misconduct can only be recorded for a rostered player or staff member.'); + } + + $highestRecordedSanction = $game->events() + ->where('type', GameEventType::MisconductRecorded) + ->get() + ->map(fn (GameEvent $event): mixed => $event->payload) + ->filter(fn (mixed $payload): bool => $payload instanceof MisconductRecordedPayload + && $payload->team === $team + && $payload->subjectType === $subjectType + && $payload->subjectId === $subjectId) + ->map(fn (MisconductRecordedPayload $payload): int => $this->misconductSanctionRank($payload->sanction)) + ->max(); + + if ($highestRecordedSanction !== null && $this->misconductSanctionRank($sanction) <= $highestRecordedSanction) { + $this->fail('This person already has the same or a higher misconduct sanction.'); + } + } + public function assertCanRecordSetEnded(Game $game): void { $state = $game->stateAt(); @@ -89,10 +235,10 @@ public function assertCanRecordSetEnded(Game $game): void $this->fail('A set can only end while it is in progress.'); } - $scoreDiff = abs($state->scoreTeamA - $state->scoreTeamB); - $highestScore = max($state->scoreTeamA, $state->scoreTeamB); - if ($highestScore < 25 || $scoreDiff < 2) { - $this->fail('A set can only end when a team has at least 25 points with a 2-point advantage.'); + $targetPoints = $this->setScoringRules->targetPoints($state->setNumber); + + if (! $this->setScoringRules->canEndSet($state->setNumber, $state->scoreTeamA, $state->scoreTeamB)) { + $this->fail("A set can only end when a team has at least {$targetPoints} points with a 2-point advantage."); } } @@ -120,8 +266,84 @@ private function hasRecordedToss(Game $game): bool ->exists(); } + private function hasRequiredToss(GameState $state): bool + { + if ($state->teamASide === null || $state->servingTeam === null) { + return false; + } + + if (! $this->requiresFifthSetToss($state)) { + return true; + } + + return $state->fifthSetLeftTeam !== null; + } + + private function requiresFifthSetToss(GameState $state): bool + { + return ! $state->gameEnded + && ! $state->setInProgress + && $state->setNumber === 4 + && $state->setsWonTeamA === 2 + && $state->setsWonTeamB === 2; + } + + private function hasSubmittedLineupForSet(Game $game, TeamAB $team, int $setNumber): bool + { + return $game->events() + ->where('type', GameEventType::LineupSubmitted) + ->where('payload->set', $setNumber) + ->where('payload->team', $team->value) + ->exists(); + } + + private function setBreakIsInProgress(Game $game, GameState $state): bool + { + if ($state->setNumber === 0 || $state->gameEnded) { + return false; + } + + $lastSetEndedAt = $this->lastSetEndedAt($game); + + if ($lastSetEndedAt === null) { + return false; + } + + return $lastSetEndedAt + ->addSeconds($this->betweenSetsDuration()) + ->isFuture(); + } + + private function lastSetEndedAt(Game $game): ?CarbonImmutable + { + /** @var GameEvent|null $setEndedEvent */ + $setEndedEvent = $game->events() + ->reorder() + ->where('type', GameEventType::SetEnded) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + + return $setEndedEvent?->created_at; + } + + private function betweenSetsDuration(): int + { + return max(0, (int) config('game.between_sets_duration', 180)); + } + private function fail(string $message): never { throw new InvalidGameEventTransition($message); } + + private function misconductSanctionRank(MisconductSanction $sanction): int + { + return match ($sanction) { + MisconductSanction::Warning => 1, + MisconductSanction::Penalty => 2, + MisconductSanction::Expulsion => 3, + MisconductSanction::Disqualification => 4, + }; + } } diff --git a/app/Services/GameState/GameStateProjector.php b/app/Services/GameState/GameStateProjector.php index 6d11e8a..612f172 100644 --- a/app/Services/GameState/GameStateProjector.php +++ b/app/Services/GameState/GameStateProjector.php @@ -6,8 +6,14 @@ use App\Data\GameState\GameState; use App\Enums\GameEventType; +use App\Enums\MisconductSanction; use App\Enums\TeamAB; +use App\Events\Payloads\CourtSidesSwappedPayload; +use App\Events\Payloads\DelayPenaltyRecordedPayload; +use App\Events\Payloads\DelayWarningRecordedPayload; +use App\Events\Payloads\ImproperRequestRecordedPayload; use App\Events\Payloads\LineupSubmittedPayload; +use App\Events\Payloads\MisconductRecordedPayload; use App\Events\Payloads\RallyEndedPayload; use App\Events\Payloads\SubstitutionCompletedPayload; use App\Events\Payloads\TimeOutRequestedPayload; @@ -18,16 +24,24 @@ class GameStateProjector { + /** @var array */ + private array $tossServingTeamByGame = []; + public function project(GameState $state, GameEvent $event): GameState { return match ($event->type) { GameEventType::TossCompleted => $this->applyTossCompleted($state, $event), GameEventType::LineupSubmitted => $this->applyLineupSubmitted($state, $event), GameEventType::RallyEnded => $this->applyRallyEnded($state, $event), + GameEventType::CourtSidesSwapped => $this->applyCourtSidesSwapped($state, $event), GameEventType::SubstitutionCompleted => $this->applySubstitutionCompleted($state, $event), GameEventType::TimeOutRequested => $this->applyTimeOutRequested($state, $event), - GameEventType::SetStarted => $this->applySetStarted($state), - GameEventType::SetEnded => $this->applySetEnded($state), + GameEventType::ImproperRequestRecorded => $this->applyImproperRequestRecorded($state, $event), + GameEventType::DelayWarningRecorded => $this->applyDelayWarningRecorded($state, $event), + GameEventType::DelayPenaltyRecorded => $this->applyDelayPenaltyRecorded($state, $event), + GameEventType::MisconductRecorded => $this->applyMisconductRecorded($state, $event), + GameEventType::SetStarted => $this->applySetStarted($state, $event), + GameEventType::SetEnded => $this->applySetEnded($state, $event), GameEventType::GameEnded => $this->applyGameEnded($state), }; } @@ -58,7 +72,7 @@ public function projectAndStore(GameEvent $event): GameStateSnapshot return GameStateSnapshot::query()->create([ 'game_id' => $event->game_id, 'game_event_id' => $event->getKey(), - ...$state->toSnapshotAttributes(), + ...$state->toAttributes(), 'created_at' => $event->created_at, ]); } @@ -67,23 +81,36 @@ private function applyTossCompleted(GameState $state, GameEvent $event): GameSta { /** @var TossCompletedPayload $payload */ $payload = $event->payload; + $state->teamASide = $payload->teamA; $state->servingTeam = $payload->serving; + if ($this->requiresFifthSetToss($state)) { + $state->fifthSetLeftTeam = $payload->leftTeam; + $state->fifthSetSideSwapped = false; + } + return $state; } - private function applyLineupSubmitted(GameState $state, GameEvent $event): GameState + private function applyCourtSidesSwapped(GameState $state, GameEvent $event): GameState { - /** @var LineupSubmittedPayload $payload */ + /** @var CourtSidesSwappedPayload $payload */ $payload = $event->payload; - if ($payload->set > $state->setNumber) { - $state->setNumber = $payload->set; - $state->resetCurrentSetCounters(); - $state->rotationTeamA = []; - $state->rotationTeamB = []; + if ($state->fifthSetLeftTeam !== null) { + $state->fifthSetLeftTeam = $this->oppositeTeam($state->fifthSetLeftTeam); } + $state->fifthSetSideSwapped = true; + + return $state; + } + + private function applyLineupSubmitted(GameState $state, GameEvent $event): GameState + { + /** @var LineupSubmittedPayload $payload */ + $payload = $event->payload; + if ($payload->team === TeamAB::TeamA) { $state->rotationTeamA = $payload->positions; } else { @@ -97,19 +124,8 @@ private function applyRallyEnded(GameState $state, GameEvent $event): GameState { /** @var RallyEndedPayload $payload */ $payload = $event->payload; - $winner = $payload->team; - if ($winner === TeamAB::TeamA) { - $state->scoreTeamA++; - } else { - $state->scoreTeamB++; - } - - if ($state->servingTeam !== null && $state->servingTeam !== $winner) { - $this->rotateTeam($state, $winner); - } - - $state->servingTeam = $winner; + $this->awardRallyTo($state, $payload->team); return $state; } @@ -130,6 +146,22 @@ private function applySubstitutionCompleted(GameState $state, GameEvent $event): return $state; } + private function applyDelayPenaltyRecorded(GameState $state, GameEvent $event): GameState + { + /** @var DelayPenaltyRecordedPayload $payload */ + $payload = $event->payload; + + if ($payload->team === TeamAB::TeamA) { + $state->delayPenaltiesTeamA++; + } else { + $state->delayPenaltiesTeamB++; + } + + $this->awardRallyTo($state, $payload->awardedTeam); + + return $state; + } + private function applyTimeOutRequested(GameState $state, GameEvent $event): GameState { /** @var TimeOutRequestedPayload $payload */ @@ -144,18 +176,77 @@ private function applyTimeOutRequested(GameState $state, GameEvent $event): Game return $state; } - private function applySetStarted(GameState $state): GameState + private function applyImproperRequestRecorded(GameState $state, GameEvent $event): GameState + { + /** @var ImproperRequestRecordedPayload $payload */ + $payload = $event->payload; + + if ($payload->team === TeamAB::TeamA) { + $state->improperRequestsTeamA++; + } else { + $state->improperRequestsTeamB++; + } + + return $state; + } + + private function applyDelayWarningRecorded(GameState $state, GameEvent $event): GameState + { + /** @var DelayWarningRecordedPayload $payload */ + $payload = $event->payload; + + if ($payload->team === TeamAB::TeamA) { + $state->delayWarningsTeamA++; + } else { + $state->delayWarningsTeamB++; + } + + return $state; + } + + private function applyMisconductRecorded(GameState $state, GameEvent $event): GameState + { + /** @var MisconductRecordedPayload $payload */ + $payload = $event->payload; + $opponent = $payload->team === TeamAB::TeamA ? TeamAB::TeamB : TeamAB::TeamA; + + match ($payload->sanction) { + MisconductSanction::Warning => $payload->team === TeamAB::TeamA + ? $state->misconductWarningsTeamA++ + : $state->misconductWarningsTeamB++, + MisconductSanction::Penalty => $payload->team === TeamAB::TeamA + ? $state->misconductPenaltiesTeamA++ + : $state->misconductPenaltiesTeamB++, + MisconductSanction::Expulsion => $payload->team === TeamAB::TeamA + ? $state->misconductExpulsionsTeamA++ + : $state->misconductExpulsionsTeamB++, + MisconductSanction::Disqualification => $payload->team === TeamAB::TeamA + ? $state->misconductDisqualificationsTeamA++ + : $state->misconductDisqualificationsTeamB++, + }; + + if ($payload->sanction === MisconductSanction::Penalty) { + $this->awardRallyTo($state, $opponent); + } + + return $state; + } + + private function applySetStarted(GameState $state, GameEvent $event): GameState { $state->setNumber = max(1, $state->setNumber + 1); $state->resetCurrentSetCounters(); - $state->rotationTeamA = []; - $state->rotationTeamB = []; + + if ($state->setNumber !== 5 || $state->fifthSetLeftTeam === null) { + $state->servingTeam = $this->servingTeamForSet($event->game_id, $state->setNumber) ?? $state->servingTeam; + } + $state->setInProgress = true; return $state; } - private function applySetEnded(GameState $state): GameState + private function applySetEnded(GameState $state, GameEvent $event): GameState { if ($state->scoreTeamA > $state->scoreTeamB) { $state->setsWonTeamA++; @@ -163,12 +254,22 @@ private function applySetEnded(GameState $state): GameState $state->setsWonTeamB++; } - if ($state->servingTeam !== null) { - $state->servingTeam = $state->servingTeam === TeamAB::TeamA - ? TeamAB::TeamB - : TeamAB::TeamA; + $nextSetNumber = max(1, $state->setNumber + 1); + + if ($nextSetNumber === 5 && $state->setsWonTeamA === 2 && $state->setsWonTeamB === 2) { + $state->servingTeam = null; + $state->fifthSetLeftTeam = null; + $state->fifthSetSideSwapped = false; + } else { + $state->servingTeam = $this->servingTeamForSet($event->game_id, $nextSetNumber) ?? $state->servingTeam; } + $state->scoreTeamA = 0; + $state->scoreTeamB = 0; + $state->timeoutsTeamA = 0; + $state->timeoutsTeamB = 0; + $state->rotationTeamA = []; + $state->rotationTeamB = []; $state->setInProgress = false; return $state; @@ -193,6 +294,21 @@ private function rotateTeam(GameState $state, TeamAB $team): void $state->rotationTeamB = $this->rotate($state->rotationTeamB); } + private function awardRallyTo(GameState $state, TeamAB $winner): void + { + if ($winner === TeamAB::TeamA) { + $state->scoreTeamA++; + } else { + $state->scoreTeamB++; + } + + if ($state->servingTeam !== null && $state->servingTeam !== $winner) { + $this->rotateTeam($state, $winner); + } + + $state->servingTeam = $winner; + } + /** * @param array $positions * @return array @@ -231,4 +347,58 @@ private function substitute(array $positions, int $playerOut, int $playerIn): ar return $positions; } + + private function servingTeamForSet(int $gameId, int $setNumber): ?TeamAB + { + $tossServingTeam = $this->tossServingTeamForGame($gameId); + + if ($tossServingTeam === null) { + return null; + } + + return $setNumber % 2 === 1 + ? $tossServingTeam + : $this->oppositeTeam($tossServingTeam); + } + + private function tossServingTeamForGame(int $gameId): ?TeamAB + { + if (array_key_exists($gameId, $this->tossServingTeamByGame)) { + return $this->tossServingTeamByGame[$gameId]; + } + + /** @var GameEvent|null $tossEvent */ + $tossEvent = GameEvent::query() + ->where('game_id', $gameId) + ->where('type', GameEventType::TossCompleted) + ->orderBy('created_at') + ->orderBy('id') + ->first(); + + if ($tossEvent === null || ! $tossEvent->payload instanceof TossCompletedPayload) { + $this->tossServingTeamByGame[$gameId] = null; + + return null; + } + + $this->tossServingTeamByGame[$gameId] = $tossEvent->payload->serving; + + return $this->tossServingTeamByGame[$gameId]; + } + + private function oppositeTeam(TeamAB $team): TeamAB + { + return $team === TeamAB::TeamA + ? TeamAB::TeamB + : TeamAB::TeamA; + } + + private function requiresFifthSetToss(GameState $state): bool + { + return ! $state->gameEnded + && ! $state->setInProgress + && $state->setNumber === 4 + && $state->setsWonTeamA === 2 + && $state->setsWonTeamB === 2; + } } diff --git a/app/Services/GameState/GameStateRecalculator.php b/app/Services/GameState/GameStateRecalculator.php index 59bb0ba..c93c2da 100644 --- a/app/Services/GameState/GameStateRecalculator.php +++ b/app/Services/GameState/GameStateRecalculator.php @@ -39,7 +39,7 @@ public function recalculate(Game $game, ?CarbonImmutable $upTo = null): void GameStateSnapshot::query()->create([ 'game_id' => $game->getKey(), 'game_event_id' => $event->getKey(), - ...$state->toSnapshotAttributes(), + ...$state->toAttributes(), 'created_at' => $event->created_at, ]); } diff --git a/app/Services/GameState/SetScoringRules.php b/app/Services/GameState/SetScoringRules.php new file mode 100644 index 0000000..0c3f0b7 --- /dev/null +++ b/app/Services/GameState/SetScoringRules.php @@ -0,0 +1,32 @@ += $this->targetPoints($setNumber) + && $scoreDifference >= self::MINIMUM_WIN_MARGIN; + } +} diff --git a/app/Services/ScoresheetDataRepository.php b/app/Services/ScoresheetDataRepository.php new file mode 100644 index 0000000..64c1f5a --- /dev/null +++ b/app/Services/ScoresheetDataRepository.php @@ -0,0 +1,82 @@ + + */ + public function playersForSide(Game $game, TeamSide $side): array + { + $players = $side === TeamSide::Home + ? $game->homePlayers() + : $game->awayPlayers(); + + return $players + ->wherePivot('is_libero', false) + ->orderByPivot('number') + ->get() + ->map(fn (Player $player): array => [ + 'player_key' => $player->getKey(), + 'number' => $player->roster->number, + 'last_name' => $player->last_name, + ]) + ->all(); + } + + /** + * @return array + */ + public function staffForSide(Game $game, TeamSide $side): array + { + $staff = $side === TeamSide::Home + ? $game->homeStaff() + : $game->awayStaff(); + + return $staff + ->orderByPivot('id') + ->get() + ->map(fn (Staff $staffMember): array => [ + 'staff_key' => $staffMember->getKey(), + 'role' => $staffMember->roster->role, + ]) + ->all(); + } + + public function latestTossPayload(Game $game): ?TossCompletedPayload + { + /** @var GameEvent|null $tossEvent */ + $tossEvent = $game->events() + ->reorder() + ->where('type', GameEventType::TossCompleted) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + + if ($tossEvent === null) { + return null; + } + + /** @var TossCompletedPayload */ + return $tossEvent->payload; + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 38b258d..fc94ae6 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,5 +1,7 @@ =7.2 <=8.5.99999" }, "conflict": { "setasign/tfpdf": "<1.31" }, "require-dev": { - "phpunit/phpunit": "^7", + "phpunit/phpunit": "^8.5.52", "setasign/fpdf": "~1.8.6", "setasign/tfpdf": "~1.33", "squizlabs/php_codesniffer": "^3.5", @@ -3402,7 +3628,7 @@ ], "support": { "issues": "https://github.com/Setasign/FPDI/issues", - "source": "https://github.com/Setasign/FPDI/tree/v2.6.4" + "source": "https://github.com/Setasign/FPDI/tree/v2.6.7" }, "funding": [ { @@ -3410,24 +3636,24 @@ "type": "tidelift" } ], - "time": "2025-08-05T09:57:14+00:00" + "time": "2026-05-13T10:16:22+00:00" }, { "name": "symfony/clock", - "version": "v8.0.0", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "psr/clock": "^1.0" }, "provide": { @@ -3467,7 +3693,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.0.0" + "source": "https://github.com/symfony/clock/tree/v8.1.0" }, "funding": [ { @@ -3487,51 +3713,53 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:46:48+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/console", - "version": "v7.4.7", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2|^8.0" + "symfony/string": "^7.4.6|^8.0.6" }, "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/lock": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3565,7 +3793,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.7" + "source": "https://github.com/symfony/console/tree/v8.1.0" }, "funding": [ { @@ -3585,24 +3813,24 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:20+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.6", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "2a178bf80f05dbbe469a337730eba79d61315262" + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/2a178bf80f05dbbe469a337730eba79d61315262", - "reference": "2a178bf80f05dbbe469a337730eba79d61315262", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -3634,7 +3862,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.6" + "source": "https://github.com/symfony/css-selector/tree/v8.1.0" }, "funding": [ { @@ -3654,20 +3882,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -3680,7 +3908,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -3705,7 +3933,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -3716,42 +3944,45 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/error-handler", - "version": "v7.4.4", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" + "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/d8aeb1abd3fef84795567850d3a567bdb5945ee5", + "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", "symfony/polyfill-php85": "^1.32", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/var-dumper": "^7.4|^8.0" }, "conflict": { - "symfony/deprecation-contracts": "<2.5", - "symfony/http-kernel": "<6.4" + "symfony/deprecation-contracts": "<2.5" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", + "symfony/console": "^7.4|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ @@ -3783,7 +4014,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + "source": "https://github.com/symfony/error-handler/tree/v8.1.0" }, "funding": [ { @@ -3803,24 +4034,25 @@ "type": "tidelift" } ], - "time": "2026-01-20T16:42:42+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.4", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { @@ -3868,7 +4100,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0" }, "funding": [ { @@ -3888,20 +4120,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:55+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", "shasum": "" }, "require": { @@ -3915,7 +4147,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -3948,7 +4180,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" }, "funding": [ { @@ -3959,32 +4191,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { "name": "symfony/finder", - "version": "v7.4.6", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" + "reference": "58d2e767a66052c1487356f953445634a8194c64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "url": "https://api.github.com/repos/symfony/finder/zipball/58d2e767a66052c1487356f953445634a8194c64", + "reference": "58d2e767a66052c1487356f953445634a8194c64", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4.1" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0" + "symfony/filesystem": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4012,7 +4248,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.6" + "source": "https://github.com/symfony/finder/tree/v8.1.0" }, "funding": [ { @@ -4032,41 +4268,40 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:40:50+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.7", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81" + "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81", - "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/af11474600f06718086c2cda4fa6fa8d0a672e7e", + "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "^1.1" }, "conflict": { - "doctrine/dbal": "<3.6", - "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + "doctrine/dbal": "<4.3" }, "require-dev": { - "doctrine/dbal": "^3.6|^4", + "doctrine/dbal": "^4.3", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0" + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4094,7 +4329,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.7" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.0" }, "funding": [ { @@ -4114,78 +4349,68 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:15:18+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.7", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1" + "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1", - "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", + "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/browser-kit": "<6.4", - "symfony/cache": "<6.4", - "symfony/config": "<6.4", - "symfony/console": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/doctrine-bridge": "<6.4", + "symfony/dependency-injection": "<8.1", "symfony/flex": "<2.10", - "symfony/form": "<6.4", - "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/mailer": "<6.4", - "symfony/messenger": "<6.4", - "symfony/translation": "<6.4", "symfony/translation-contracts": "<2.5", - "symfony/twig-bridge": "<6.4", - "symfony/validator": "<6.4", - "symfony/var-dumper": "<6.4", - "twig/twig": "<3.12" + "symfony/var-dumper": "<8.1", + "symfony/web-profiler-bundle": "<8.1", + "twig/twig": "<3.21" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^6.4|^7.0|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", - "symfony/dom-crawler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^7.1|^8.0", - "symfony/routing": "^6.4|^7.0|^8.0", - "symfony/serializer": "^7.1|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^6.4|^7.0|^8.0", - "symfony/validator": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^8.1", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21" }, "type": "library", "autoload": { @@ -4213,7 +4438,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.7" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.0" }, "funding": [ { @@ -4233,43 +4458,39 @@ "type": "tidelift" } ], - "time": "2026-03-06T16:33:18+00:00" + "time": "2026-05-29T08:46:08+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.6", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" + "reference": "9418d772df3a03a142e3bc06f602adb2b8724877" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "url": "https://api.github.com/repos/symfony/mailer/zipball/9418d772df3a03a142e3bc06f602adb2b8724877", + "reference": "9418d772df3a03a142e3bc06f602adb2b8724877", "shasum": "" }, "require": { "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.2", + "php": ">=8.4.1", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/mime": "^7.2|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<6.4", - "symfony/messenger": "<6.4", - "symfony/mime": "<6.4", - "symfony/twig-bridge": "<6.4" + "symfony/http-client-contracts": "<2.5" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/twig-bridge": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4297,7 +4518,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.6" + "source": "https://github.com/symfony/mailer/tree/v8.1.0" }, "funding": [ { @@ -4317,44 +4538,41 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/mime", - "version": "v7.4.7", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1" + "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1", + "url": "https://api.github.com/repos/symfony/mime/zipball/b164ae7e3f7915aacfe9ee155f2f358502440664", + "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4.1", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "egulias/email-validator": "~3.0.0", "phpdocumentor/reflection-docblock": "<5.2|>=7", - "phpdocumentor/type-resolver": "<1.5.1", - "symfony/mailer": "<6.4", - "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + "phpdocumentor/type-resolver": "<1.5.1" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^5.2|^6.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^6.4|^7.0|^8.0", - "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4386,7 +4604,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.7" + "source": "https://github.com/symfony/mime/tree/v8.1.0" }, "funding": [ { @@ -4406,20 +4624,20 @@ "type": "tidelift" } ], - "time": "2026-03-05T15:24:09+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -4469,7 +4687,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -4489,20 +4707,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -4551,7 +4769,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -4571,20 +4789,20 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { @@ -4638,7 +4856,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { @@ -4658,20 +4876,20 @@ "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -4723,7 +4941,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -4743,20 +4961,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", "shasum": "" }, "require": { @@ -4808,7 +5026,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" }, "funding": [ { @@ -4828,20 +5046,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -4892,7 +5110,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -4912,20 +5130,20 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "name": "symfony/polyfill-php84", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -4943,7 +5161,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php83\\": "" + "Symfony\\Polyfill\\Php84\\": "" }, "classmap": [ "Resources/stubs" @@ -4963,7 +5181,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -4972,7 +5190,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -4992,20 +5210,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { - "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "name": "symfony/polyfill-php85", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { @@ -5023,7 +5241,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php84\\": "" + "Symfony\\Polyfill\\Php85\\": "" }, "classmap": [ "Resources/stubs" @@ -5043,7 +5261,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -5052,7 +5270,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -5072,20 +5290,20 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { - "name": "symfony/polyfill-php85", - "version": "v1.33.0", + "name": "symfony/polyfill-php86", + "version": "v1.38.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad", + "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad", "shasum": "" }, "require": { @@ -5103,7 +5321,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php85\\": "" + "Symfony\\Polyfill\\Php86\\": "" }, "classmap": [ "Resources/stubs" @@ -5123,7 +5341,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -5132,7 +5350,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php86/tree/v1.38.0" }, "funding": [ { @@ -5152,20 +5370,20 @@ "type": "tidelift" } ], - "time": "2025-06-23T16:12:55+00:00" + "time": "2026-05-25T11:52:35+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { @@ -5215,7 +5433,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -5235,24 +5453,24 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/process", - "version": "v7.4.5", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "608476f4604102976d687c483ac63a79ba18cc97" + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", - "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -5280,7 +5498,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.5" + "source": "https://github.com/symfony/process/tree/v8.1.0" }, "funding": [ { @@ -5300,38 +5518,33 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/routing", - "version": "v7.4.6", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" + "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", + "url": "https://api.github.com/repos/symfony/routing/zipball/fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", + "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3" }, - "conflict": { - "symfony/config": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/yaml": "<6.4" - }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -5365,7 +5578,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.6" + "source": "https://github.com/symfony/routing/tree/v8.1.0" }, "funding": [ { @@ -5385,20 +5598,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -5416,7 +5629,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -5452,7 +5665,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -5472,24 +5685,24 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "symfony/string", - "version": "v8.0.6", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8", "symfony/polyfill-intl-grapheme": "^1.33", "symfony/polyfill-intl-normalizer": "^1.0", @@ -5542,7 +5755,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.6" + "source": "https://github.com/symfony/string/tree/v8.1.0" }, "funding": [ { @@ -5562,24 +5775,24 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/translation", - "version": "v8.0.6", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b" + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", + "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-mbstring": "^1.0", "symfony/translation-contracts": "^3.6.1" }, @@ -5635,7 +5848,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.6" + "source": "https://github.com/symfony/translation/tree/v8.1.0" }, "funding": [ { @@ -5655,20 +5868,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", "shasum": "" }, "require": { @@ -5681,7 +5894,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -5717,7 +5930,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" }, "funding": [ { @@ -5737,28 +5950,28 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { "name": "symfony/uid", - "version": "v7.4.4", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "url": "https://api.github.com/repos/symfony/uid/zipball/7393f157a55f7e70a4de0334435c55a5a8fe749a", + "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -5795,7 +6008,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.4" + "source": "https://github.com/symfony/uid/tree/v8.1.0" }, "funding": [ { @@ -5815,35 +6028,35 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:30:35+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.6", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" + "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", + "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", "twig/twig": "^3.12" }, "bin": [ @@ -5882,7 +6095,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.0" }, "funding": [ { @@ -5902,7 +6115,7 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:20+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6045,23 +6258,23 @@ }, { "name": "voku/portable-ascii", - "version": "2.0.3", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/voku/portable-ascii.git", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", "shasum": "" }, "require": { - "php": ">=7.0.0" + "php": ">=7.1.0" }, "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" }, "suggest": { "ext-intl": "Use Intl for transliterator_transliterate() support" @@ -6091,7 +6304,7 @@ ], "support": { "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" }, "funding": [ { @@ -6115,22 +6328,22 @@ "type": "tidelift" } ], - "time": "2024-11-21T01:49:47+00:00" + "time": "2026-04-26T05:33:54+00:00" } ], "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.19.0", + "version": "v7.20.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6" + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", - "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", "shasum": "" }, "require": { @@ -6144,9 +6357,9 @@ "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", "phpunit/php-file-iterator": "^6.0.1 || ^7", "phpunit/php-timer": "^8 || ^9", - "phpunit/phpunit": "^12.5.9 || ^13", + "phpunit/phpunit": "^12.5.14 || ^13.0.5", "sebastian/environment": "^8.0.3 || ^9", - "symfony/console": "^7.4.4 || ^8.0.4", + "symfony/console": "^7.4.7 || ^8.0.7", "symfony/process": "^7.4.5 || ^8.0.5" }, "require-dev": { @@ -6154,11 +6367,11 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.38", - "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.12", - "phpstan/phpstan-strict-rules": "^2.0.8", - "symfony/filesystem": "^7.4.0 || ^8.0.1" + "phpstan/phpstan": "^2.1.44", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "symfony/filesystem": "^7.4.6 || ^8.0.6" }, "bin": [ "bin/paratest", @@ -6198,7 +6411,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.19.0" + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" }, "funding": [ { @@ -6210,97 +6423,221 @@ "type": "paypal" } ], - "time": "2026-02-06T10:53:26+00:00" + "time": "2026-03-29T15:46:14+00:00" }, { - "name": "doctrine/deprecations", - "version": "1.1.6", + "name": "composer/pcre", + "version": "3.3.2", "source": { "type": "git", - "url": "https://github.com/doctrine/deprecations.git", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^7.4 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=14" + "phpstan/phpstan": "<1.11.10" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^14", - "phpstan/phpstan": "1.4.10 || 2.1.30", - "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", - "psr/log": "^1 || ^2 || ^3" - }, - "suggest": { - "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" }, "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, "autoload": { "psr-4": { - "Doctrine\\Deprecations\\": "src" + "Composer\\Pcre\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", - "homepage": "https://www.doctrine-project.org/", + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], "support": { - "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.6" - }, - "time": "2026-02-07T07:09:04+00:00" - }, - { - "name": "driftingly/rector-laravel", - "version": "2.1.10", - "source": { - "type": "git", - "url": "https://github.com/driftingly/rector-laravel.git", - "reference": "8f90418f1f17e29852a54791fd859547b23f00f4" + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/8f90418f1f17e29852a54791fd859547b23f00f4", - "reference": "8f90418f1f17e29852a54791fd859547b23f00f4", + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", "shasum": "" }, "require": { - "php": ">=8.3", - "rector/rector": "^2.3.0", - "symplify/rule-doc-generator-contracts": "^11.2", - "webmozart/assert": "^1.11 || ^2.0" + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" }, "require-dev": { - "nette/utils": "^4.0", - "nikic/php-parser": "^5.6", - "phpstan/extension-installer": "^1.3", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-deprecation-rules": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpstan/phpstan-webmozart-assert": "^2.0", - "phpunit/phpunit": "^10.5", - "symplify/rule-doc-generator": "^12.2", - "tightenco/duster": "^3.1", - "tracy/tracy": "^2.10" + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" }, - "type": "rector-extension", - "extra": { - "rector": { - "includes": [ - "config/config.php" - ] + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" } }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "driftingly/rector-laravel", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/driftingly/rector-laravel.git", + "reference": "0dbdd842dfdbc821cfe5f47ca7326e2502b2a107" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/0dbdd842dfdbc821cfe5f47ca7326e2502b2a107", + "reference": "0dbdd842dfdbc821cfe5f47ca7326e2502b2a107", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "rector/rector": "^2.2.7", + "webmozart/assert": "^1.11 || ^2.0" + }, + "type": "rector-extension", "autoload": { "psr-4": { "RectorLaravel\\": "src" @@ -6313,9 +6650,9 @@ "description": "Rector upgrades rules for Laravel Framework", "support": { "issues": "https://github.com/driftingly/rector-laravel/issues", - "source": "https://github.com/driftingly/rector-laravel/tree/2.1.10" + "source": "https://github.com/driftingly/rector-laravel/tree/2.5.0" }, - "time": "2026-03-03T11:27:42+00:00" + "time": "2026-06-02T16:22:14+00:00" }, { "name": "fakerphp/faker", @@ -6666,16 +7003,16 @@ }, { "name": "larastan/larastan", - "version": "v3.9.3", + "version": "v3.10.0", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65" + "reference": "2970f83398154178a739609c244577267c7ee8eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65", - "reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65", + "url": "https://api.github.com/repos/larastan/larastan/zipball/2970f83398154178a739609c244577267c7ee8eb", + "reference": "2970f83398154178a739609c244577267c7ee8eb", "shasum": "" }, "require": { @@ -6689,17 +7026,17 @@ "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", "php": "^8.2", - "phpstan/phpstan": "^2.1.32" + "phpstan/phpstan": "^2.2.0" }, "require-dev": { - "doctrine/coding-standard": "^13", + "doctrine/coding-standard": "^14", "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", "mockery/mockery": "^1.6.12", "nikic/php-parser": "^5.4", "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", "phpstan/phpstan-deprecation-rules": "^2.0.1", - "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8" + "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8 || ^13.1.8" }, "suggest": { "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", @@ -6744,7 +7081,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.9.3" + "source": "https://github.com/larastan/larastan/tree/v3.10.0" }, "funding": [ { @@ -6752,20 +7089,82 @@ "type": "github" } ], - "time": "2026-02-20T12:07:12+00:00" + "time": "2026-05-28T08:00:58+00:00" + }, + { + "name": "laravel/agent-detector", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/agent-detector.git", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/agent-detector/zipball/90694b9256099591cf9e55d08c18ba7a00bf099f", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f", + "shasum": "" + }, + "require": { + "php": "^8.2.0" + }, + "require-dev": { + "laravel/pint": "^1.24.0", + "pestphp/pest": "^3.8.5|^4.1.0", + "pestphp/pest-plugin-type-coverage": "^3.0|^4.0.2", + "phpstan/phpstan": "^2.1.26", + "rector/rector": "^2.1.7", + "symfony/var-dumper": "^7.3.3" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Laravel\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Detect if code is running in an AI agent or automated development environment", + "homepage": "https://github.com/laravel/agent-detector", + "keywords": [ + "Agent", + "ai", + "automation", + "claude", + "cursor", + "detection", + "devin", + "php" + ], + "support": { + "issues": "https://github.com/laravel/agent-detector/issues", + "source": "https://github.com/laravel/agent-detector" + }, + "time": "2026-04-29T18:32:34+00:00" }, { "name": "laravel/boost", - "version": "v2.2.3", + "version": "v2.4.9", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "44ab65a5455c2d6fceb71d6145f8d5d89c02d889" + "reference": "f0359e55f6c3782023a35baf1d3df817053d69e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/44ab65a5455c2d6fceb71d6145f8d5d89c02d889", - "reference": "44ab65a5455c2d6fceb71d6145f8d5d89c02d889", + "url": "https://api.github.com/repos/laravel/boost/zipball/f0359e55f6c3782023a35baf1d3df817053d69e8", + "reference": "f0359e55f6c3782023a35baf1d3df817053d69e8", "shasum": "" }, "require": { @@ -6774,7 +7173,7 @@ "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", "illuminate/routing": "^11.45.3|^12.41.1|^13.0", "illuminate/support": "^11.45.3|^12.41.1|^13.0", - "laravel/mcp": "^0.5.1|^0.6.0", + "laravel/mcp": "^0.7.1", "laravel/prompts": "^0.3.10", "laravel/roster": "^0.5.0", "php": "^8.2" @@ -6818,20 +7217,20 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-03-06T20:20:28+00:00" + "time": "2026-06-04T10:33:57+00:00" }, { "name": "laravel/mcp", - "version": "v0.6.0", + "version": "v0.7.2", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "28860a10ca0cc5433e25d897ba7af844e6c7b6a2" + "reference": "08962a276357f89164f78b38407c08187ab26cfe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/28860a10ca0cc5433e25d897ba7af844e6c7b6a2", - "reference": "28860a10ca0cc5433e25d897ba7af844e6c7b6a2", + "url": "https://api.github.com/repos/laravel/mcp/zipball/08962a276357f89164f78b38407c08187ab26cfe", + "reference": "08962a276357f89164f78b38407c08187ab26cfe", "shasum": "" }, "require": { @@ -6845,7 +7244,8 @@ "illuminate/routing": "^11.45.3|^12.41.1|^13.0", "illuminate/support": "^11.45.3|^12.41.1|^13.0", "illuminate/validation": "^11.45.3|^12.41.1|^13.0", - "php": "^8.2" + "php": "^8.2", + "symfony/process": "^7.4.5|^8.0.5" }, "require-dev": { "laravel/pint": "^1.20", @@ -6891,20 +7291,20 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2026-02-24T08:43:06+00:00" + "time": "2026-05-22T11:45:29+00:00" }, { "name": "laravel/pail", - "version": "v1.2.6", + "version": "v1.2.7", "source": { "type": "git", "url": "https://github.com/laravel/pail.git", - "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", - "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", "shasum": "" }, "require": { @@ -6971,20 +7371,105 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2026-02-09T13:44:54+00:00" + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/pao", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pao.git", + "reference": "519eecdefb9d5da362876376b52207c8f11b477c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pao/zipball/519eecdefb9d5da362876376b52207c8f11b477c", + "reference": "519eecdefb9d5da362876376b52207c8f11b477c", + "shasum": "" + }, + "require": { + "laravel/agent-detector": "^2.0.2", + "php": "^8.3" + }, + "conflict": { + "laravel/framework": "<12.0.0", + "nunomaduro/collision": "<8.9.3", + "pestphp/pest": "<4.6.3 || >=6.0.0", + "phpunit/phpunit": "<12.5.23 || >=13.0.0 <13.1.7 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.20.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench": "^10.11.0 || ^11.1.0", + "pestphp/pest": "^4.7.2 || ^5.0.0", + "pestphp/pest-plugin-type-coverage": "^4.0.4 || ^5.0.0", + "phpstan/phpstan": "^2.2.1", + "rector/rector": "^2.4.5", + "symfony/process": "^7.4.8 || ^8.1.0", + "symfony/var-dumper": "^7.4.8 || ^8.1.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Laravel\\Pao\\Drivers\\Pest\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Laravel\\Pao\\Laravel\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Laravel\\Pao\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Agent-optimized output for PHP testing tools", + "keywords": [ + "Agent", + "PHPStan", + "ai", + "dev", + "paratest", + "pest", + "php", + "phpunit", + "rector", + "testing" + ], + "support": { + "issues": "https://github.com/laravel/pao/issues", + "source": "https://github.com/laravel/pao" + }, + "time": "2026-06-01T07:26:51+00:00" }, { "name": "laravel/pint", - "version": "v1.27.1", + "version": "v1.29.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", - "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", "shasum": "" }, "require": { @@ -6995,13 +7480,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.93.1", - "illuminate/view": "^12.51.0", - "larastan/larastan": "^3.9.2", - "laravel-zero/framework": "^12.0.5", + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.5" + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.3" }, "bin": [ "builds/pint" @@ -7038,7 +7524,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-02-10T20:00:20+00:00" + "time": "2026-04-20T15:26:14+00:00" }, { "name": "laravel/roster", @@ -7103,16 +7589,16 @@ }, { "name": "laravel/sail", - "version": "v1.53.0", + "version": "v1.62.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb" + "reference": "3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/e340eaa2bea9b99192570c48ed837155dbf24fbb", - "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb", + "url": "https://api.github.com/repos/laravel/sail/zipball/3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e", + "reference": "3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e", "shasum": "" }, "require": { @@ -7162,7 +7648,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-02-06T12:16:02+00:00" + "time": "2026-05-27T04:02:01+00:00" }, { "name": "mockery/mockery", @@ -7309,23 +7795,23 @@ }, { "name": "nunomaduro/collision", - "version": "v8.9.1", + "version": "v8.9.4", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", - "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", "shasum": "" }, "require": { "filp/whoops": "^2.18.4", "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.4.4 || ^8.0.4" + "symfony/console": "^7.4.8 || ^8.0.8" }, "conflict": { "laravel/framework": "<11.48.0 || >=14.0.0", @@ -7333,12 +7819,12 @@ }, "require-dev": { "brianium/paratest": "^7.8.5", - "larastan/larastan": "^3.9.2", - "laravel/framework": "^11.48.0 || ^12.52.0", - "laravel/pint": "^1.27.1", - "orchestra/testbench-core": "^9.12.0 || ^10.9.0", - "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", - "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" }, "type": "library", "extra": { @@ -7401,45 +7887,47 @@ "type": "patreon" } ], - "time": "2026-02-17T17:33:08+00:00" + "time": "2026-04-21T14:04:20+00:00" }, { "name": "pestphp/pest", - "version": "v4.4.1", + "version": "v4.7.2", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "f96a1b27864b585b0b29b0ee7331176726f7e54a" + "reference": "40b88b62ef8a7c6fcae5fc28f1fa747f601c131b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/f96a1b27864b585b0b29b0ee7331176726f7e54a", - "reference": "f96a1b27864b585b0b29b0ee7331176726f7e54a", + "url": "https://api.github.com/repos/pestphp/pest/zipball/40b88b62ef8a7c6fcae5fc28f1fa747f601c131b", + "reference": "40b88b62ef8a7c6fcae5fc28f1fa747f601c131b", "shasum": "" }, "require": { - "brianium/paratest": "^7.19.0", - "nunomaduro/collision": "^8.9.0", + "brianium/paratest": "^7.20.0", + "composer/xdebug-handler": "^3.0.5", + "nunomaduro/collision": "^8.9.4", "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", - "pestphp/pest-plugin-arch": "^4.0.0", + "pestphp/pest-plugin-arch": "^4.0.2", "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.12", - "symfony/process": "^7.4.5|^8.0.5" + "phpunit/phpunit": "^12.5.28", + "symfony/process": "^7.4.13|^8.1.0" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.12", + "phpunit/phpunit": ">12.5.28", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { + "mrpunyapal/peststan": "^0.2.10", "pestphp/pest-dev-tools": "^4.1.0", - "pestphp/pest-plugin-browser": "^4.3.0", - "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.20" + "pestphp/pest-plugin-browser": "^4.3.1", + "pestphp/pest-plugin-type-coverage": "^4.0.4", + "psy/psysh": "^0.12.23" }, "bin": [ "bin/pest" @@ -7466,6 +7954,7 @@ "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", "Pest\\Plugins\\Shard", + "Pest\\Plugins\\Tia", "Pest\\Plugins\\Parallel" ] }, @@ -7505,7 +7994,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.4.1" + "source": "https://github.com/pestphp/pest/tree/v4.7.2" }, "funding": [ { @@ -7517,7 +8006,7 @@ "type": "github" } ], - "time": "2026-02-17T15:27:18+00:00" + "time": "2026-06-01T06:08:59+00:00" }, { "name": "pestphp/pest-plugin", @@ -7591,26 +8080,26 @@ }, { "name": "pestphp/pest-plugin-arch", - "version": "v4.0.0", + "version": "v4.0.2", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-arch.git", - "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d" + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/25bb17e37920ccc35cbbcda3b00d596aadf3e58d", - "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", "shasum": "" }, "require": { "pestphp/pest-plugin": "^4.0.0", "php": "^8.3", - "ta-tikoma/phpunit-architecture-test": "^0.8.5" + "ta-tikoma/phpunit-architecture-test": "^0.8.7" }, "require-dev": { - "pestphp/pest": "^4.0.0", - "pestphp/pest-dev-tools": "^4.0.0" + "pestphp/pest": "^4.4.6", + "pestphp/pest-dev-tools": "^4.1.0" }, "type": "library", "extra": { @@ -7645,7 +8134,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.0" + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.2" }, "funding": [ { @@ -7657,7 +8146,7 @@ "type": "github" } ], - "time": "2025-08-20T13:10:51+00:00" + "time": "2026-04-10T17:20:19+00:00" }, { "name": "pestphp/pest-plugin-laravel", @@ -8038,16 +8527,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "6.0.2", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf" + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/897b5986ece6b4f9d8413fea345c7d49c757d6bf", - "reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", "shasum": "" }, "require": { @@ -8097,9 +8586,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.2" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" }, - "time": "2026-03-01T18:43:49+00:00" + "time": "2026-03-18T20:49:53+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -8256,11 +8745,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.40", + "version": "2.2.2", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", - "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6", "shasum": "" }, "require": { @@ -8283,6 +8772,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -8305,20 +8805,20 @@ "type": "github" } ], - "time": "2026-02-23T15:04:35+00:00" + "time": "2026-06-05T09:00:01+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.3", + "version": "12.5.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" + "reference": "186dab580576598076de6818596d12b61801880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", "shasum": "" }, "require": { @@ -8327,16 +8827,15 @@ "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", "php": ">=8.3", - "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.0.3", - "sebastian/lines-of-code": "^4.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", "sebastian/version": "^6.0", "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.5.1" + "phpunit/phpunit": "^12.5.28" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -8374,7 +8873,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" }, "funding": [ { @@ -8394,7 +8893,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T06:01:44+00:00" + "time": "2026-06-01T13:24:19+00:00" }, { "name": "phpunit/php-file-iterator", @@ -8655,16 +9154,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.12", + "version": "12.5.28", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "418e06b3b46b0d54bad749ff4907fc7dfb530199" + "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/418e06b3b46b0d54bad749ff4907fc7dfb530199", - "reference": "418e06b3b46b0d54bad749ff4907fc7dfb530199", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5895d05f5bf421ed230fbd76e1277e4b8955def4", + "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4", "shasum": "" }, "require": { @@ -8678,20 +9177,20 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-code-coverage": "^12.5.6", "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.4", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", - "sebastian/exporter": "^7.0.2", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", "sebastian/recursion-context": "^7.0.1", - "sebastian/type": "^6.0.3", + "sebastian/type": "^6.0.4", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" }, @@ -8733,49 +9232,33 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.12" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.28" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-16T08:34:36+00:00" + "time": "2026-05-27T14:01:10+00:00" }, { "name": "rector/rector", - "version": "2.3.8", + "version": "2.4.5", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c" + "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/bbd37aedd8df749916cffa2a947cfc4714d1ba2c", - "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c", + "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.38" + "phpstan/phpstan": "^2.1.56" }, "conflict": { "rector/rector-doctrine": "*", @@ -8809,7 +9292,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.8" + "source": "https://github.com/rectorphp/rector/tree/2.4.5" }, "funding": [ { @@ -8817,27 +9300,27 @@ "type": "github" } ], - "time": "2026-02-22T09:45:50+00:00" + "time": "2026-05-26T21:03:22+00:00" }, { "name": "sebastian/cli-parser", - "version": "4.2.0", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -8866,7 +9349,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" }, "funding": [ { @@ -8886,20 +9369,20 @@ "type": "tidelift" } ], - "time": "2025-09-14T09:36:45+00:00" + "time": "2026-05-17T05:29:34+00:00" }, { "name": "sebastian/comparator", - "version": "7.1.4", + "version": "7.1.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + "reference": "7c65c1e79836812819705b473a90c12399542485" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", "shasum": "" }, "require": { @@ -8907,10 +9390,10 @@ "ext-mbstring": "*", "php": ">=8.3", "sebastian/diff": "^7.0", - "sebastian/exporter": "^7.0" + "sebastian/exporter": "^7.0.3" }, "require-dev": { - "phpunit/phpunit": "^12.2" + "phpunit/phpunit": "^12.5.25" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -8958,7 +9441,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" }, "funding": [ { @@ -8978,7 +9461,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:28:48+00:00" + "time": "2026-05-21T04:45:25+00:00" }, { "name": "sebastian/complexity", @@ -9107,23 +9590,23 @@ }, { "name": "sebastian/environment", - "version": "8.0.3", + "version": "8.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.26" }, "suggest": { "ext-posix": "*" @@ -9131,7 +9614,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -9159,7 +9642,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" }, "funding": [ { @@ -9179,29 +9662,29 @@ "type": "tidelift" } ], - "time": "2025-08-12T14:11:56+00:00" + "time": "2026-05-25T13:40:20+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.2", + "version": "7.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7" + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", "shasum": "" }, "require": { "ext-mbstring": "*", "php": ">=8.3", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -9249,7 +9732,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" }, "funding": [ { @@ -9269,30 +9752,30 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:16:11+00:00" + "time": "2026-05-20T04:37:17+00:00" }, { "name": "sebastian/global-state", - "version": "8.0.2", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", "shasum": "" }, "require": { "php": ">=8.3", "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.28" }, "type": "library", "extra": { @@ -9323,7 +9806,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" }, "funding": [ { @@ -9343,28 +9826,28 @@ "type": "tidelift" } ], - "time": "2025-08-29T11:29:25+00:00" + "time": "2026-06-01T15:10:33+00:00" }, { "name": "sebastian/lines-of-code", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -9393,15 +9876,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:28+00:00" + "time": "2026-05-19T16:22:07+00:00" }, { "name": "sebastian/object-enumerator", @@ -9595,23 +10090,23 @@ }, { "name": "sebastian/type", - "version": "6.0.3", + "version": "6.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -9640,7 +10135,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { @@ -9660,7 +10155,7 @@ "type": "tidelift" } ], - "time": "2025-08-09T06:57:12+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { "name": "sebastian/version", @@ -9770,27 +10265,28 @@ }, { "name": "symfony/yaml", - "version": "v8.0.6", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "5f006c50a981e1630bbb70ad409c5d85f9a716e0" + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/5f006c50a981e1630bbb70ad409c5d85f9a716e0", - "reference": "5f006c50a981e1630bbb70ad409c5d85f9a716e0", + "url": "https://api.github.com/repos/symfony/yaml/zipball/efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8" }, "conflict": { "symfony/console": "<7.4" }, "require-dev": { - "symfony/console": "^7.4|^8.0" + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" }, "bin": [ "Resources/bin/yaml-lint" @@ -9821,7 +10317,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.6" + "source": "https://github.com/symfony/yaml/tree/v8.1.0" }, "funding": [ { @@ -9841,66 +10337,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" - }, - { - "name": "symplify/rule-doc-generator-contracts", - "version": "11.2.0", - "source": { - "type": "git", - "url": "https://github.com/symplify/rule-doc-generator-contracts.git", - "reference": "479cfcfd46047f80624aba931d9789e50475b5c6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symplify/rule-doc-generator-contracts/zipball/479cfcfd46047f80624aba931d9789e50475b5c6", - "reference": "479cfcfd46047f80624aba931d9789e50475b5c6", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "require-dev": { - "php-parallel-lint/php-parallel-lint": "^1.3", - "phpstan/extension-installer": "^1.2", - "rector/rector": "^0.15.10", - "symplify/easy-ci": "^11.1", - "symplify/easy-coding-standard": "^11.1", - "symplify/easy-testing": "^11.1", - "symplify/phpstan-extensions": "^11.1", - "symplify/phpstan-rules": "11.2.3.72", - "tomasvotruba/unused-public": "^0.0.34" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "11.2-dev" - } - }, - "autoload": { - "psr-4": { - "Symplify\\RuleDocGenerator\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Contracts for production code of RuleDocGenerator", - "support": { - "source": "https://github.com/symplify/rule-doc-generator-contracts/tree/11.2.0" - }, - "funding": [ - { - "url": "https://www.paypal.me/rectorphp", - "type": "custom" - }, - { - "url": "https://github.com/tomasvotruba", - "type": "github" - } - ], - "time": "2024-03-18T22:02:54+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", @@ -10013,22 +10450,22 @@ }, { "name": "tomasvotruba/type-coverage", - "version": "2.1.0", + "version": "2.2.1", "source": { "type": "git", "url": "https://github.com/TomasVotruba/type-coverage.git", - "reference": "468354b3964120806a69890cbeb3fcf005876391" + "reference": "4087caa4639bdd4c646f2984bc333efeddf69e4b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/TomasVotruba/type-coverage/zipball/468354b3964120806a69890cbeb3fcf005876391", - "reference": "468354b3964120806a69890cbeb3fcf005876391", + "url": "https://api.github.com/repos/TomasVotruba/type-coverage/zipball/4087caa4639bdd4c646f2984bc333efeddf69e4b", + "reference": "4087caa4639bdd4c646f2984bc333efeddf69e4b", "shasum": "" }, "require": { "nette/utils": "^3.2 || ^4.0", "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.0" + "phpstan/phpstan": "^2.1.33" }, "type": "phpstan-extension", "extra": { @@ -10054,7 +10491,7 @@ ], "support": { "issues": "https://github.com/TomasVotruba/type-coverage/issues", - "source": "https://github.com/TomasVotruba/type-coverage/tree/2.1.0" + "source": "https://github.com/TomasVotruba/type-coverage/tree/2.2.1" }, "funding": [ { @@ -10066,20 +10503,20 @@ "type": "github" } ], - "time": "2025-12-05T16:38:02+00:00" + "time": "2026-05-26T08:14:01+00:00" }, { "name": "webmozart/assert", - "version": "2.1.6", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", "shasum": "" }, "require": { @@ -10095,7 +10532,11 @@ }, "type": "library", "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, "branch-alias": { + "dev-master": "2.0-dev", "dev-feature/2-0": "2.0-dev" } }, @@ -10126,9 +10567,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.6" + "source": "https://github.com/webmozarts/assert/tree/2.4.0" }, - "time": "2026-02-27T10:28:38+00:00" + "time": "2026-05-20T13:07:01+00:00" } ], "aliases": [], diff --git a/config/auth.php b/config/auth.php index 7d1eb0d..d7568ff 100644 --- a/config/auth.php +++ b/config/auth.php @@ -1,5 +1,7 @@ [ 'users' => [ 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', App\Models\User::class), + 'model' => env('AUTH_MODEL', User::class), ], // 'users' => [ diff --git a/config/cache.php b/config/cache.php index b32aead..a5ae0fa 100644 --- a/config/cache.php +++ b/config/cache.php @@ -114,4 +114,17 @@ 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + /* + |-------------------------------------------------------------------------- + | Cache Serializable Classes + |-------------------------------------------------------------------------- + | + | This option controls which PHP classes may be unserialized from cache. + | Set to false to disable object unserialization entirely, or provide an + | explicit allow-list of classes your application stores in cache. + | + */ + + 'serializable_classes' => false, + ]; diff --git a/config/database.php b/config/database.php index 7bf4986..6627e4f 100644 --- a/config/database.php +++ b/config/database.php @@ -1,6 +1,7 @@ true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \Pdo\Mysql::ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : Mysql::ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], @@ -79,7 +80,7 @@ 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \Pdo\Mysql::ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : Mysql::ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], diff --git a/config/game.php b/config/game.php new file mode 100644 index 0000000..fa58954 --- /dev/null +++ b/config/game.php @@ -0,0 +1,31 @@ + (int) env('GAME_BETWEEN_SETS_DURATION', 180), + + /* + |-------------------------------------------------------------------------- + | Timeout Duration + |-------------------------------------------------------------------------- + | + | The number of seconds shown on the timeout countdown timer. Reduce this + | during development to avoid waiting the full 30 seconds. + | + */ + + 'timeout_duration' => (int) env('GAME_TIMEOUT_DURATION', 30), + +]; diff --git a/config/livewire.php b/config/livewire.php new file mode 100644 index 0000000..d8f0ae5 --- /dev/null +++ b/config/livewire.php @@ -0,0 +1,282 @@ + [ + resource_path('views/components'), + resource_path('views/livewire'), + ], + + /* + |--------------------------------------------------------------------------- + | Component Namespaces + |--------------------------------------------------------------------------- + | + | This value sets default namespaces that will be used to resolve view-based + | components like single-file and multi-file components. These folders'll + | also be referenced when creating new components via the make command. + | + */ + + 'component_namespaces' => [ + 'layouts' => resource_path('views/layouts'), + 'pages' => resource_path('views/pages'), + ], + + /* + |--------------------------------------------------------------------------- + | Page Layout + |--------------------------------------------------------------------------- + | The view that will be used as the layout when rendering a single component as + | an entire page via `Route::livewire('/post/create', 'pages::create-post')`. + | In this case, the content of pages::create-post will render into $slot. + | + */ + + 'component_layout' => 'layouts::app', + + /* + |--------------------------------------------------------------------------- + | Lazy Loading Placeholder + |--------------------------------------------------------------------------- + | Livewire allows you to lazy load components that would otherwise slow down + | the initial page load. Every component can have a custom placeholder or + | you can define the default placeholder view for all components below. + | + */ + + 'component_placeholder' => null, // Example: 'placeholders::skeleton' + + /* + |--------------------------------------------------------------------------- + | Make Command + |--------------------------------------------------------------------------- + | This value determines the default configuration for the artisan make command + | You can configure the component type (sfc, mfc, class) and whether to use + | the high-voltage (⚡) emoji as a prefix in the sfc|mfc component names. + | + */ + + 'make_command' => [ + 'type' => 'sfc', // Options: 'sfc', 'mfc', 'class' + 'emoji' => true, // Options: true, false + 'with' => [ + 'js' => false, + 'css' => false, + 'test' => false, + ], + ], + + /* + |--------------------------------------------------------------------------- + | Class Namespace + |--------------------------------------------------------------------------- + | + | This value sets the root class namespace for Livewire component classes in + | your application. This value will change where component auto-discovery + | finds components. It's also referenced by the file creation commands. + | + */ + + 'class_namespace' => 'App\\Livewire', + + /* + |--------------------------------------------------------------------------- + | Class Path + |--------------------------------------------------------------------------- + | + | This value is used to specify the path where Livewire component class files + | are created when running creation commands like `artisan make:livewire`. + | This path is customizable to match your projects directory structure. + | + */ + + 'class_path' => app_path('Livewire'), + + /* + |--------------------------------------------------------------------------- + | View Path + |--------------------------------------------------------------------------- + | + | This value is used to specify where Livewire component Blade templates are + | stored when running file creation commands like `artisan make:livewire`. + | It is also used if you choose to omit a component's render() method. + | + */ + + 'view_path' => resource_path('views/livewire'), + + /* + |--------------------------------------------------------------------------- + | Temporary File Uploads + |--------------------------------------------------------------------------- + | + | Livewire handles file uploads by storing uploads in a temporary directory + | before the file is stored permanently. All file uploads are directed to + | a global endpoint for temporary storage. You may configure this below: + | + */ + + 'temporary_file_upload' => [ + 'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default' + 'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB) + 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' + 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' + 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... + 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', + 'mov', 'avi', 'wmv', 'mp3', 'm4a', + 'jpg', 'jpeg', 'mpga', 'webp', 'wma', + ], + 'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated... + 'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs... + ], + + /* + |--------------------------------------------------------------------------- + | Render On Redirect + |--------------------------------------------------------------------------- + | + | This value determines if Livewire will run a component's `render()` method + | after a redirect has been triggered using something like `redirect(...)` + | Setting this to true will render the view once more before redirecting + | + */ + + 'render_on_redirect' => false, + + /* + |--------------------------------------------------------------------------- + | Eloquent Model Binding + |--------------------------------------------------------------------------- + | + | Previous versions of Livewire supported binding directly to eloquent model + | properties using wire:model by default. However, this behavior has been + | deemed too "magical" and has therefore been put under a feature flag. + | + */ + + 'legacy_model_binding' => false, + + /* + |--------------------------------------------------------------------------- + | Auto-inject Frontend Assets + |--------------------------------------------------------------------------- + | + | By default, Livewire automatically injects its JavaScript and CSS into the + | and of pages containing Livewire components. By disabling + | this behavior, you need to use @livewireStyles and @livewireScripts. + | + */ + + 'inject_assets' => true, + + /* + |--------------------------------------------------------------------------- + | Navigate (SPA mode) + |--------------------------------------------------------------------------- + | + | By adding `wire:navigate` to links in your Livewire application, Livewire + | will prevent the default link handling and instead request those pages + | via AJAX, creating an SPA-like effect. Configure this behavior here. + | + */ + + 'navigate' => [ + 'show_progress_bar' => true, + 'progress_bar_color' => '#2299dd', + ], + + /* + |--------------------------------------------------------------------------- + | HTML Morph Markers + |--------------------------------------------------------------------------- + | + | Livewire intelligently "morphs" existing HTML into the newly rendered HTML + | after each update. To make this process more reliable, Livewire injects + | "markers" into the rendered Blade surrounding @if, @class & @foreach. + | + */ + + 'inject_morph_markers' => true, + + /* + |--------------------------------------------------------------------------- + | Smart Wire Keys + |--------------------------------------------------------------------------- + | + | Livewire uses loops and keys used within loops to generate smart keys that + | are applied to nested components that don't have them. This makes using + | nested components more reliable by ensuring that they all have keys. + | + */ + + 'smart_wire_keys' => true, + + /* + |--------------------------------------------------------------------------- + | Pagination Theme + |--------------------------------------------------------------------------- + | + | When enabling Livewire's pagination feature by using the `WithPagination` + | trait, Livewire will use Tailwind templates to render pagination views + | on the page. If you want Bootstrap CSS, you can specify: "bootstrap" + | + */ + + 'pagination_theme' => 'tailwind', + + /* + |--------------------------------------------------------------------------- + | Release Token + |--------------------------------------------------------------------------- + | + | This token is stored client-side and sent along with each request to check + | a users session to see if a new release has invalidated it. If there is + | a mismatch it will throw an error and prompt for a browser refresh. + | + */ + + 'release_token' => 'a', + + /* + |--------------------------------------------------------------------------- + | CSP Safe + |--------------------------------------------------------------------------- + | + | This config is used to determine if Livewire will use the CSP-safe version + | of Alpine in its bundle. This is useful for applications that are using + | strict Content Security Policy (CSP) to protect against XSS attacks. + | + */ + + 'csp_safe' => false, + + /* + |--------------------------------------------------------------------------- + | Payload Guards + |--------------------------------------------------------------------------- + | + | These settings protect against malicious or oversized payloads that could + | cause denial of service. The default values should feel reasonable for + | most web applications. Each can be set to null to disable the limit. + | + */ + + 'payload' => [ + 'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes + 'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths + 'max_calls' => 50, // Maximum method calls per request + 'max_components' => 20, // Maximum components per batch request + ], +]; diff --git a/database/factories/PlayerFactory.php b/database/factories/PlayerFactory.php index 055c7d0..7ff7ee0 100644 --- a/database/factories/PlayerFactory.php +++ b/database/factories/PlayerFactory.php @@ -44,4 +44,12 @@ public function forCountry(string $code): static { return $this->withLocale($this->getLocaleForCountry($code)); } + + public function named(string $firstName, string $lastName): static + { + return $this->state(fn (array $attributes) => [ + 'first_name' => $firstName, + 'last_name' => $lastName, + ]); + } } diff --git a/database/factories/StaffFactory.php b/database/factories/StaffFactory.php index 3fa42b7..ad84a08 100644 --- a/database/factories/StaffFactory.php +++ b/database/factories/StaffFactory.php @@ -4,6 +4,7 @@ namespace Database\Factories; +use App\Enums\StaffRole; use App\Models\Staff; use App\Models\Team; use Illuminate\Database\Eloquent\Factories\Factory; @@ -26,6 +27,7 @@ public function definition(): array 'team_id' => Team::factory(), 'first_name' => $this->faker->firstName(), 'last_name' => $this->faker->lastName(), + 'role' => $this->faker->randomElement(StaffRole::cases()), ]; } @@ -44,4 +46,39 @@ public function forCountry(string $code): static { return $this->withLocale($this->getLocaleForCountry($code)); } + + public function named(string $firstName, string $lastName): static + { + return $this->state(fn (array $attributes) => [ + 'first_name' => $firstName, + 'last_name' => $lastName, + ]); + } + + public function withRole(StaffRole $role): static + { + return $this->state(fn (array $attributes) => [ + 'role' => $role, + ]); + } + + public function asCoach(): static + { + return $this->withRole(StaffRole::Coach); + } + + public function asAssistantCoach(): static + { + return $this->withRole(StaffRole::AssistantCoach); + } + + public function asDoctor(): static + { + return $this->withRole(StaffRole::Doctor); + } + + public function asTherapist(): static + { + return $this->withRole(StaffRole::Therapist); + } } diff --git a/database/migrations/2026_03_29_153813_add_team_a_side_to_game_state_snapshots_table.php b/database/migrations/2026_03_29_153813_add_team_a_side_to_game_state_snapshots_table.php new file mode 100644 index 0000000..330daf1 --- /dev/null +++ b/database/migrations/2026_03_29_153813_add_team_a_side_to_game_state_snapshots_table.php @@ -0,0 +1,62 @@ +string('team_a_side')->nullable()->after('substitutions_team_b'); + }); + + $tossEvents = DB::table('game_events') + ->select(['id', 'game_id', 'created_at', 'payload']) + ->where('type', 'toss_completed') + ->orderBy('game_id') + ->orderBy('created_at') + ->orderBy('id') + ->get(); + + foreach ($tossEvents as $tossEvent) { + $payload = json_decode((string) $tossEvent->payload, true); + + if (! is_array($payload)) { + continue; + } + + $teamASide = $payload['teamA'] ?? null; + + if (! is_string($teamASide)) { + continue; + } + + DB::table('game_state_snapshots') + ->where('game_id', $tossEvent->game_id) + ->where(function ($query) use ($tossEvent): void { + $query->where('created_at', '>', $tossEvent->created_at) + ->orWhere(function ($nestedQuery) use ($tossEvent): void { + $nestedQuery->where('created_at', $tossEvent->created_at) + ->where('game_event_id', '>=', $tossEvent->id); + }); + }) + ->update(['team_a_side' => $teamASide]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('game_state_snapshots', function (Blueprint $table) { + $table->dropColumn('team_a_side'); + }); + } +}; diff --git a/database/migrations/2026_05_12_171528_add_fifth_set_left_team_to_game_state_snapshots_table.php b/database/migrations/2026_05_12_171528_add_fifth_set_left_team_to_game_state_snapshots_table.php new file mode 100644 index 0000000..e2e0017 --- /dev/null +++ b/database/migrations/2026_05_12_171528_add_fifth_set_left_team_to_game_state_snapshots_table.php @@ -0,0 +1,28 @@ +string('fifth_set_left_team')->nullable()->after('team_a_side'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('game_state_snapshots', function (Blueprint $table) { + $table->dropColumn('fifth_set_left_team'); + }); + } +}; diff --git a/database/migrations/2026_05_13_164126_add_fifth_set_side_swapped_to_game_state_snapshots_table.php b/database/migrations/2026_05_13_164126_add_fifth_set_side_swapped_to_game_state_snapshots_table.php new file mode 100644 index 0000000..39768c5 --- /dev/null +++ b/database/migrations/2026_05_13_164126_add_fifth_set_side_swapped_to_game_state_snapshots_table.php @@ -0,0 +1,28 @@ +boolean('fifth_set_side_swapped')->default(false)->after('fifth_set_left_team'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('game_state_snapshots', function (Blueprint $table) { + $table->dropColumn('fifth_set_side_swapped'); + }); + } +}; diff --git a/database/migrations/2026_05_22_163747_add_improper_requests_to_game_state_snapshots_table.php b/database/migrations/2026_05_22_163747_add_improper_requests_to_game_state_snapshots_table.php new file mode 100644 index 0000000..5389718 --- /dev/null +++ b/database/migrations/2026_05_22_163747_add_improper_requests_to_game_state_snapshots_table.php @@ -0,0 +1,32 @@ +unsignedTinyInteger('improper_requests_team_a')->default(0)->after('substitutions_team_b'); + $table->unsignedTinyInteger('improper_requests_team_b')->default(0)->after('improper_requests_team_a'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('game_state_snapshots', function (Blueprint $table) { + $table->dropColumn([ + 'improper_requests_team_a', + 'improper_requests_team_b', + ]); + }); + } +}; diff --git a/database/migrations/2026_05_22_165203_add_delay_warnings_to_game_state_snapshots_table.php b/database/migrations/2026_05_22_165203_add_delay_warnings_to_game_state_snapshots_table.php new file mode 100644 index 0000000..f761413 --- /dev/null +++ b/database/migrations/2026_05_22_165203_add_delay_warnings_to_game_state_snapshots_table.php @@ -0,0 +1,32 @@ +unsignedTinyInteger('delay_warnings_team_a')->default(0)->after('improper_requests_team_b'); + $table->unsignedTinyInteger('delay_warnings_team_b')->default(0)->after('delay_warnings_team_a'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('game_state_snapshots', function (Blueprint $table) { + $table->dropColumn([ + 'delay_warnings_team_a', + 'delay_warnings_team_b', + ]); + }); + } +}; diff --git a/database/migrations/2026_05_22_170040_add_delay_penalties_to_game_state_snapshots_table.php b/database/migrations/2026_05_22_170040_add_delay_penalties_to_game_state_snapshots_table.php new file mode 100644 index 0000000..0b7c2ce --- /dev/null +++ b/database/migrations/2026_05_22_170040_add_delay_penalties_to_game_state_snapshots_table.php @@ -0,0 +1,32 @@ +unsignedTinyInteger('delay_penalties_team_a')->default(0)->after('delay_warnings_team_b'); + $table->unsignedTinyInteger('delay_penalties_team_b')->default(0)->after('delay_penalties_team_a'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('game_state_snapshots', function (Blueprint $table) { + $table->dropColumn([ + 'delay_penalties_team_a', + 'delay_penalties_team_b', + ]); + }); + } +}; diff --git a/database/migrations/2026_05_22_171403_add_misconduct_counts_to_game_state_snapshots_table.php b/database/migrations/2026_05_22_171403_add_misconduct_counts_to_game_state_snapshots_table.php new file mode 100644 index 0000000..d5a27f0 --- /dev/null +++ b/database/migrations/2026_05_22_171403_add_misconduct_counts_to_game_state_snapshots_table.php @@ -0,0 +1,44 @@ +unsignedTinyInteger('misconduct_warnings_team_a')->default(0)->after('delay_penalties_team_b'); + $table->unsignedTinyInteger('misconduct_warnings_team_b')->default(0)->after('misconduct_warnings_team_a'); + $table->unsignedTinyInteger('misconduct_penalties_team_a')->default(0)->after('misconduct_warnings_team_b'); + $table->unsignedTinyInteger('misconduct_penalties_team_b')->default(0)->after('misconduct_penalties_team_a'); + $table->unsignedTinyInteger('misconduct_expulsions_team_a')->default(0)->after('misconduct_penalties_team_b'); + $table->unsignedTinyInteger('misconduct_expulsions_team_b')->default(0)->after('misconduct_expulsions_team_a'); + $table->unsignedTinyInteger('misconduct_disqualifications_team_a')->default(0)->after('misconduct_expulsions_team_b'); + $table->unsignedTinyInteger('misconduct_disqualifications_team_b')->default(0)->after('misconduct_disqualifications_team_a'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('game_state_snapshots', function (Blueprint $table) { + $table->dropColumn([ + 'misconduct_warnings_team_a', + 'misconduct_warnings_team_b', + 'misconduct_penalties_team_a', + 'misconduct_penalties_team_b', + 'misconduct_expulsions_team_a', + 'misconduct_expulsions_team_b', + 'misconduct_disqualifications_team_a', + 'misconduct_disqualifications_team_b', + ]); + }); + } +}; diff --git a/database/migrations/2026_06_01_174415_add_role_to_staff_table.php b/database/migrations/2026_06_01_174415_add_role_to_staff_table.php new file mode 100644 index 0000000..e84be25 --- /dev/null +++ b/database/migrations/2026_06_01_174415_add_role_to_staff_table.php @@ -0,0 +1,33 @@ +string('role')->nullable()->after('last_name'); + }); + + Schema::table('staff', function (Blueprint $table) { + $table->string('role')->default(StaffRole::Coach->value)->nullable(false)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('staff', function (Blueprint $table) { + $table->dropColumn('role'); + }); + } +}; diff --git a/database/migrations/2026_06_01_175307_add_rosters_submitted_to_games_table.php b/database/migrations/2026_06_01_175307_add_rosters_submitted_to_games_table.php new file mode 100644 index 0000000..12eb0f1 --- /dev/null +++ b/database/migrations/2026_06_01_175307_add_rosters_submitted_to_games_table.php @@ -0,0 +1,28 @@ +boolean('rosters_submitted')->default(false)->after('category'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('games', function (Blueprint $table) { + $table->dropColumn('rosters_submitted'); + }); + } +}; diff --git a/database/seeders/ControlledGameSeeder.php b/database/seeders/ControlledGameSeeder.php new file mode 100644 index 0000000..6ce9f4a --- /dev/null +++ b/database/seeders/ControlledGameSeeder.php @@ -0,0 +1,143 @@ +seedControlledGame(); + } finally { + Config::set('game.between_sets_duration', $betweenSetsDuration); + } + } + + private function seedControlledGame(): void + { + $championship = Championship::factory()->named('Dev Controlled Championship')->create(); + + $homeTeam = Team::factory()->create([ + 'name' => self::HOME_TEAM_NAME, + 'country_code' => 'ITA', + ]); + + $awayTeam = Team::factory()->create([ + 'name' => self::AWAY_TEAM_NAME, + 'country_code' => 'FRA', + ]); + + Player::factory() + ->for($homeTeam) + ->forCountry($homeTeam->country_code) + ->count(13) + ->create(); + + Player::factory() + ->for($awayTeam) + ->forCountry($awayTeam->country_code) + ->count(12) + ->create(); + + $homeStaff = [ + Staff::factory()->for($homeTeam)->forCountry($homeTeam->country_code)->asCoach()->create(), + Staff::factory()->for($homeTeam)->forCountry($homeTeam->country_code)->asAssistantCoach()->create(), + Staff::factory()->for($homeTeam)->forCountry($homeTeam->country_code)->asAssistantCoach()->create(), + Staff::factory()->for($homeTeam)->forCountry($homeTeam->country_code)->asDoctor()->create(), + Staff::factory()->for($homeTeam)->forCountry($homeTeam->country_code)->asTherapist()->create(), + ]; + + $awayStaff = [ + Staff::factory()->for($awayTeam)->forCountry($awayTeam->country_code)->asCoach()->create(), + Staff::factory()->for($awayTeam)->forCountry($awayTeam->country_code)->asAssistantCoach()->create(), + Staff::factory()->for($awayTeam)->forCountry($awayTeam->country_code)->asDoctor()->create(), + ]; + + $game = Game::factory() + ->for($championship, 'championship') + ->betweenTeams($homeTeam, $awayTeam) + ->withMatchNumber(1) + ->withCountryCode($homeTeam->country_code) + ->at('Bologna', 'PalaDozza') + ->scheduledAt(CarbonImmutable::now()->addDay()->setTime(20, 30)) + ->create(); + + foreach (OfficialRole::cases() as $role) { + $game->addOfficial(Official::factory()->create(), $role); + } + + $homeNumbers = range(1, 13); + $awayNumbers = range(20, 31); + + foreach ($homeTeam->players()->orderBy('id')->get() as $index => $player) { + $number = $homeNumbers[$index]; + + $game->addPlayer( + player: $player, + number: $number, + isCaptain: $index === 0, + isLibero: $number === 13, + ); + } + + foreach ($awayTeam->players()->orderBy('id')->get() as $index => $player) { + $game->addPlayer( + player: $player, + number: $awayNumbers[$index], + isCaptain: $index === 0, + isLibero: false, + ); + } + + foreach ($homeStaff as $staff) { + $game->addStaff($staff); + } + + foreach ($awayStaff as $staff) { + $game->addStaff($staff); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + $homeLineup = [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]; + $awayLineup = [1 => 20, 2 => 21, 3 => 22, 4 => 23, 5 => 24, 6 => 25]; + + $setWinners = [TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB]; + + foreach ($setWinners as $index => $winner) { + $setNumber = $index + 1; + $game->recordLineup($setNumber, TeamAB::TeamA, $homeLineup); + $game->recordLineup($setNumber, TeamAB::TeamB, $awayLineup); + $game->recordSetStarted(); + + for ($i = 0; $i < 25; $i++) { + $game->recordRallyWinner($winner); + } + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6e2d17a..0c2a565 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -5,7 +5,6 @@ namespace Database\Seeders; use App\Enums\OfficialRole; -use App\Enums\StaffRole; use App\Models\Championship; use App\Models\Game; use App\Models\Official; @@ -38,11 +37,11 @@ public function run(): void ->count(14) ->create(); - Staff::factory() - ->for($team) - ->forCountry($team->country_code) - ->count(5) - ->create(); + Staff::factory()->for($team)->forCountry($team->country_code)->asCoach()->create(); + Staff::factory()->for($team)->forCountry($team->country_code)->asAssistantCoach()->create(); + Staff::factory()->for($team)->forCountry($team->country_code)->asAssistantCoach()->create(); + Staff::factory()->for($team)->forCountry($team->country_code)->asTherapist()->create(); + Staff::factory()->for($team)->forCountry($team->country_code)->asDoctor()->create(); }); /** @var Collection $officials */ @@ -55,7 +54,7 @@ public function run(): void ->scheduledAt(now()->addDays(1)) ->create(); - // Assign officials, players and staff to matches + // Assign officials to the match. Leave rosters empty so the UI starts at roster submission. $roles = OfficialRole::cases(); /** @var Collection $shuffledOfficials */ $shuffledOfficials = $officials @@ -68,37 +67,6 @@ public function run(): void $game->addOfficial($official, $role); } - // Players and Staff for both teams - foreach ([$game->homeTeam, $game->awayTeam] as $team) { - $usedNumbers = []; - /** @var Player $player */ - $captain = mt_rand(1, 14); - do { - $libero1 = mt_rand(1, 14); - $libero2 = mt_rand(1, 14); - } while ($captain === $libero1 || $captain === $libero2 || $libero1 === $libero2); - foreach ($team->players as $index => $player) { - $playerNumber = fake()->unique()->numberBetween(1, 99); - $game->addPlayer( - player: $player, - number: $playerNumber, - isCaptain: $index === $captain, - isLibero: $index === $libero1 || $index === $libero2 - ); - } - - $staffRoles = [ - StaffRole::Coach, - StaffRole::AssistantCoach, - StaffRole::AssistantCoach, - StaffRole::Therapist, - StaffRole::Doctor, - ]; - foreach ($team->staff as $index => $staff) { - $game->addStaff(staff: $staff, role: $staffRoles[$index]); - } - } - User::factory() ->testUser() ->create(); diff --git a/package-lock.json b/package-lock.json index 4ff5b0e..e7552ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -31,9 +31,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -48,9 +48,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -65,9 +65,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -82,9 +82,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -99,9 +99,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -116,9 +116,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -133,9 +133,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -150,9 +150,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -167,9 +167,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -184,9 +184,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -201,9 +201,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -218,9 +218,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -235,9 +235,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -252,9 +252,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -269,9 +269,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -286,9 +286,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -303,9 +303,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -320,9 +320,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -337,9 +337,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -354,9 +354,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -371,9 +371,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -388,9 +388,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -405,9 +405,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -422,9 +422,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -439,9 +439,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -506,9 +506,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", "cpu": [ "arm" ], @@ -520,9 +520,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", "cpu": [ "arm64" ], @@ -534,9 +534,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", "cpu": [ "arm64" ], @@ -548,9 +548,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", "cpu": [ "x64" ], @@ -562,9 +562,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", "cpu": [ "arm64" ], @@ -576,9 +576,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", "cpu": [ "x64" ], @@ -590,9 +590,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", "cpu": [ "arm" ], @@ -604,9 +604,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", "cpu": [ "arm" ], @@ -618,9 +618,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", "cpu": [ "arm64" ], @@ -632,9 +632,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", "cpu": [ "arm64" ], @@ -646,9 +646,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", "cpu": [ "loong64" ], @@ -660,9 +660,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", "cpu": [ "loong64" ], @@ -674,9 +674,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", "cpu": [ "ppc64" ], @@ -688,9 +688,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", "cpu": [ "ppc64" ], @@ -702,9 +702,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", "cpu": [ "riscv64" ], @@ -716,9 +716,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", "cpu": [ "riscv64" ], @@ -730,9 +730,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", "cpu": [ "s390x" ], @@ -744,9 +744,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", "cpu": [ "x64" ], @@ -758,9 +758,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", "cpu": [ "x64" ], @@ -772,9 +772,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", "cpu": [ "x64" ], @@ -786,9 +786,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", "cpu": [ "arm64" ], @@ -800,9 +800,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", "cpu": [ "arm64" ], @@ -814,9 +814,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", "cpu": [ "ia32" ], @@ -828,9 +828,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", "cpu": [ "x64" ], @@ -842,9 +842,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", "cpu": [ "x64" ], @@ -856,49 +856,49 @@ ] }, "node_modules/@tailwindcss/node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", - "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", + "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", - "lightningcss": "1.31.1", + "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.1" + "tailwindcss": "4.3.0" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", - "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-x64": "4.2.1", - "@tailwindcss/oxide-freebsd-x64": "4.2.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-x64-musl": "4.2.1", - "@tailwindcss/oxide-wasm32-wasi": "4.2.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", - "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", "cpu": [ "arm64" ], @@ -913,9 +913,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", - "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", "cpu": [ "arm64" ], @@ -930,9 +930,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", - "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", "cpu": [ "x64" ], @@ -947,9 +947,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", - "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", "cpu": [ "x64" ], @@ -964,9 +964,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", - "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", "cpu": [ "arm" ], @@ -981,9 +981,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", - "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", "cpu": [ "arm64" ], @@ -998,9 +998,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", - "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", "cpu": [ "arm64" ], @@ -1015,9 +1015,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", - "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", "cpu": [ "x64" ], @@ -1032,9 +1032,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", - "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", "cpu": [ "x64" ], @@ -1049,9 +1049,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", - "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1067,10 +1067,10 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, @@ -1079,9 +1079,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", - "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", "cpu": [ "arm64" ], @@ -1096,9 +1096,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", - "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", "cpu": [ "x64" ], @@ -1113,27 +1113,40 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", - "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.1", - "@tailwindcss/oxide": "4.2.1", - "tailwindcss": "4.2.1" + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" }, "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1168,15 +1181,16 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/call-bind-apply-helpers": { @@ -1296,6 +1310,24 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1339,14 +1371,14 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", - "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", + "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -1373,9 +1405,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -1402,9 +1434,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1415,32 +1447,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/escalade": { @@ -1472,9 +1504,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -1643,9 +1675,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -1655,6 +1687,20 @@ "node": ">= 0.4" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1666,9 +1712,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -1696,9 +1742,9 @@ } }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -1712,23 +1758,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -1747,9 +1793,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -1768,9 +1814,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -1789,9 +1835,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -1810,9 +1856,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -1831,9 +1877,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], @@ -1852,9 +1898,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], @@ -1873,9 +1919,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -1894,9 +1940,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -1915,9 +1961,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -1936,9 +1982,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -1999,10 +2045,17 @@ "node": ">= 0.6" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -2026,9 +2079,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -2039,9 +2092,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -2059,7 +2112,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2068,11 +2121,14 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/require-directory": { "version": "2.1.1", @@ -2085,13 +2141,13 @@ } }, "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -2101,31 +2157,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", "fsevents": "~2.3.2" } }, @@ -2207,16 +2263,16 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", - "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", "dev": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -2228,14 +2284,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2262,9 +2318,9 @@ "license": "0BSD" }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "dev": true, "license": "MIT", "dependencies": { @@ -2348,9 +2404,9 @@ } }, "node_modules/vite-plugin-full-reload/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { diff --git a/phpunit.xml b/phpunit.xml index d703241..6ab7d4f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,9 +5,6 @@ colors="true" > - - tests/Unit - tests/Feature diff --git a/public/icons/red-card.svg b/public/icons/red-card.svg new file mode 100644 index 0000000..d379f9b --- /dev/null +++ b/public/icons/red-card.svg @@ -0,0 +1,7 @@ + + Red card + A red volleyball sanction card shaped like a playing card. + + + + diff --git a/public/icons/yellow-card.svg b/public/icons/yellow-card.svg new file mode 100644 index 0000000..45d92c9 --- /dev/null +++ b/public/icons/yellow-card.svg @@ -0,0 +1,7 @@ + + Yellow card + A yellow volleyball sanction card shaped like a playing card. + + + + diff --git a/public/icons/yellow-red-card.svg b/public/icons/yellow-red-card.svg new file mode 100644 index 0000000..2ca760e --- /dev/null +++ b/public/icons/yellow-red-card.svg @@ -0,0 +1,14 @@ + + Yellow and red card + A yellow card behind a red card, fanned from the bottom. + + + + + + + + + + + diff --git a/public/icons/yellow-red-side-by-side-card.svg b/public/icons/yellow-red-side-by-side-card.svg new file mode 100644 index 0000000..0e62811 --- /dev/null +++ b/public/icons/yellow-red-side-by-side-card.svg @@ -0,0 +1,14 @@ + + Yellow and red cards side by side + A yellow volleyball sanction card next to a red volleyball sanction card. + + + + + + + + + + + diff --git a/rector.php b/rector.php index a436820..7a2b392 100644 --- a/rector.php +++ b/rector.php @@ -3,20 +3,25 @@ declare(strict_types=1); use Rector\Config\RectorConfig; +use RectorLaravel\Set\LaravelLevelSetList; +use RectorLaravel\Set\LaravelSetProvider; return RectorConfig::configure() ->withPaths([ __DIR__.'/app', __DIR__.'/bootstrap', __DIR__.'/config', - __DIR__.'/public', - __DIR__.'/resources', __DIR__.'/routes', __DIR__.'/tests', ]) - // uncomment to reach your current PHP version + ->withSkip([ + __DIR__.'/bootstrap/cache', + ]) ->withPhpSets() - ->withSetProviders(\RectorLaravel\Set\LaravelSetProvider::class) + ->withSets([ + LaravelLevelSetList::UP_TO_LARAVEL_130, + ]) + ->withSetProviders(LaravelSetProvider::class) ->withComposerBased(laravel: true) ->withTypeCoverageLevel(0) ->withDeadCodeLevel(0) diff --git a/resources/css/app.css b/resources/css/app.css index 3e6abea..694f746 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,4 +1,5 @@ @import 'tailwindcss'; +@import '../../vendor/livewire/flux/dist/flux.css'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../storage/framework/views/*.php'; @@ -6,6 +7,6 @@ @source '../**/*.js'; @theme { - --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + --font-sans: 'Saira', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; } diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php new file mode 100644 index 0000000..360b588 --- /dev/null +++ b/resources/views/layouts/app.blade.php @@ -0,0 +1,23 @@ + + + + + + + {{ $title ?? config('app.name') }} + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + @livewireStyles + + + {{ $slot }} + + @livewireScripts + @fluxScripts + + diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php new file mode 100644 index 0000000..4818cd9 --- /dev/null +++ b/resources/views/livewire/court.blade.php @@ -0,0 +1,573 @@ +
+ @php + $leftCourtPositionClasses = [ + 1 => 'left-[12%] bottom-[14%]', + 2 => 'left-[38%] bottom-[14%]', + 3 => 'left-[38%] top-1/2 -translate-y-1/2', + 4 => 'left-[38%] top-[14%]', + 5 => 'left-[12%] top-[14%]', + 6 => 'left-[12%] top-1/2 -translate-y-1/2', + ]; + + $rightCourtPositionClasses = [ + 1 => 'right-[12%] top-[14%]', + 2 => 'right-[38%] top-[14%]', + 3 => 'right-[38%] top-1/2 -translate-y-1/2', + 4 => 'right-[38%] bottom-[14%]', + 5 => 'right-[12%] bottom-[14%]', + 6 => 'right-[12%] top-1/2 -translate-y-1/2', + ]; + + $leftOutsidePositionClasses = [ + 1 => '-left-10 bottom-[14%]', + ]; + + $rightOutsidePositionClasses = [ + 1 => '-right-10 top-[14%]', + ]; + + $leftMarkerTone = $leftTeam === \App\Enums\TeamAB::TeamA ? 'bg-blue-600' : 'bg-red-600'; + $rightMarkerTone = $rightTeam === \App\Enums\TeamAB::TeamA ? 'bg-blue-600' : 'bg-red-600'; + + $misconductControls = [ + ['sanction' => 'warning', 'label' => 'Minor misconduct', 'icon' => asset('icons/yellow-card.svg')], + ['sanction' => 'penalty', 'label' => 'Penalty', 'icon' => asset('icons/red-card.svg')], + ['sanction' => 'expulsion', 'label' => 'Expulsion', 'icon' => asset('icons/yellow-red-card.svg')], + ['sanction' => 'disqualification', 'label' => 'Disqualification', 'icon' => asset('icons/yellow-red-side-by-side-card.svg')], + ]; + + $delayControls = [ + ['sanction' => 'delay-warning', 'label' => 'Delay warning', 'icon' => asset('icons/yellow-card.svg')], + ['sanction' => 'delay-penalty', 'label' => 'Delay penalty', 'icon' => asset('icons/red-card.svg')], + ]; + @endphp + +
+
+ @if ($showSanctionControls) + + @endif + +
+
+
+
+
+ + @foreach ($leftRotation as $position => $number) + @php + $isServingPlayer = $position === 1 && $servingTeam === $leftTeam; + $markerPositionClass = $isServingPlayer + ? ($leftOutsidePositionClasses[$position] ?? $leftCourtPositionClasses[$position] ?? '') + : ($leftCourtPositionClasses[$position] ?? ''); + @endphp +
+ {{ $number }} +
+ @endforeach + + @foreach ($rightRotation as $position => $number) + @php + $isServingPlayer = $position === 1 && $servingTeam === $rightTeam; + $markerPositionClass = $isServingPlayer + ? ($rightOutsidePositionClasses[$position] ?? $rightCourtPositionClasses[$position] ?? '') + : ($rightCourtPositionClasses[$position] ?? ''); + @endphp +
+ {{ $number }} +
+ @endforeach +
+ + @if ($showSanctionControls) + + @endif +
+ +
+
+ + +
+ + @if ($showRosters) +
+
+ +
+ +
+ +
+
+ @else +
+ @endif + +
+ + +
+
+
+ + +
+
+ Record delay warning? + + Confirm that you want to record a delay warning for this team. + +
+ +
+ + + Cancel + + + Confirm + +
+
+
+ + +
+
+ Record delay penalty? + + Confirm that you want to record a delay penalty for this team. + +
+ +
+ + + Cancel + + + Confirm + +
+
+
+ + +
+
+ Select person + + Choose who should receive {{ $pendingMisconductSanctionLabel ? strtolower($pendingMisconductSanctionLabel) : 'this sanction' }}. + +
+ +
+
+ Players +
+ @forelse ($misconductSubjects['players'] as $subject) + + @empty + No players are rostered for this team. + @endforelse +
+
+ +
+ Staff +
+ @forelse ($misconductSubjects['staff'] as $subject) + + {{ $subject['marker'] }} + @if ($subject['unavailable']) + + @endif + + @empty + No staff are rostered for this team. + @endforelse +
+
+
+ +
+ + + Cancel + +
+
+
+ + +
+
+ Record misconduct? + + Confirm {{ $pendingMisconductSanctionLabel ? strtolower($pendingMisconductSanctionLabel) : 'this sanction' }} for {{ $pendingMisconductSubjectLabel ?? 'this person' }}. + +
+ +
+ + + Cancel + + + Confirm + +
+
+
+ + +
+
+ {{ $delaySanctionRecordedTitle }} + {{ $delaySanctionRecordedMessage }} +
+ +
+ + Dismiss + +
+
+
+
diff --git a/resources/views/livewire/game.blade.php b/resources/views/livewire/game.blade.php new file mode 100644 index 0000000..2380290 --- /dev/null +++ b/resources/views/livewire/game.blade.php @@ -0,0 +1,45 @@ +
+
+
+ @unless ($isBeforeInitialToss) + + @endunless + + + + +
+
+ + +
+ Set {{ $justEndedSetNumber }} finished + + {{ $setWinnerCode }} won + {{ $finalScoreWinner }} – {{ $finalScoreLoser }} + +
+ Continue to next set +
+
+
+ + +
+ Teams to change court +
+ Dismiss +
+
+
+
diff --git a/resources/views/livewire/lineup-submission.blade.php b/resources/views/livewire/lineup-submission.blade.php new file mode 100644 index 0000000..42dfd1d --- /dev/null +++ b/resources/views/livewire/lineup-submission.blade.php @@ -0,0 +1,75 @@ +
+ @if ($canSubmitLineup) + + Submit Lineup + + + +
+ {{ $this->modalHeading() }} + +
+ @for ($position = 1; $position <= 6; $position++) +
+ +
+ @endfor +
+ + @error('submit') + {{ $message }} + @enderror + + @if ($rosterNumbers !== []) +
+ @foreach ($rosterNumbers as $rosterNumber) + + {{ $rosterNumber }} + + @endforeach +
+ @endif + +
+ + Submit +
+
+
+ @endif +
diff --git a/resources/views/livewire/rally-winner-controls.blade.php b/resources/views/livewire/rally-winner-controls.blade.php new file mode 100644 index 0000000..9455b02 --- /dev/null +++ b/resources/views/livewire/rally-winner-controls.blade.php @@ -0,0 +1,21 @@ +
+ @if ($canRecordRallyWinner) + @php $team = $side === 'left' ? $leftTeam : $rightTeam; @endphp + + Winner + + + @error('submit') + {{ $message }} + @enderror + @endif +
diff --git a/resources/views/livewire/roster-submission.blade.php b/resources/views/livewire/roster-submission.blade.php new file mode 100644 index 0000000..97640c0 --- /dev/null +++ b/resources/views/livewire/roster-submission.blade.php @@ -0,0 +1,296 @@ +
+ @if ($isBeforeInitialToss && ! $hasSubmittedRosters) +
+ + Submit rosters + +
+ + +
+
+ Submit rosters + + Enter each selected player's roster number, choose one captain, and tick the libero checkbox where needed. Check the staff who will take part in the game. + +
+ +
+
+
+ Home Team + {{ $homeTeamCode }} +
+ +
+ Players +
+ Name + Captain? + Number + Libero? +
+
+ @forelse ($homePlayers as $player) +
+
+
{{ $player->first_name }} {{ $player->last_name }}
+
+
+ +
+
+ +
+
+ +
+
+ @error('homeRosterInputs.'.$player->getKey()) + {{ $message }} + @enderror + @empty + No players available. + @endforelse +
+ @error('homeRosterInputs') + {{ $message }} + @enderror + @error('homeCaptainSelection') + {{ $message }} + @enderror +
+ +
+ Staff +
+ @forelse ($homeStaff as $staffMember) + + @empty + No staff available. + @endforelse +
+
+
+ +
+
+ Away Team + {{ $awayTeamCode }} +
+ +
+ Players +
+ Name + Captain? + Number + Libero? +
+
+ @forelse ($awayPlayers as $player) +
+
+
{{ $player->first_name }} {{ $player->last_name }}
+
+
+ +
+
+ +
+
+ +
+
+ @error('awayRosterInputs.'.$player->getKey()) + {{ $message }} + @enderror + @empty + No players available. + @endforelse +
+ @error('awayRosterInputs') + {{ $message }} + @enderror + @error('awayCaptainSelection') + {{ $message }} + @enderror +
+ +
+ Staff +
+ @forelse ($awayStaff as $staffMember) + + @empty + No staff available. + @endforelse +
+
+
+
+ + @error('rosters') + {{ $message }} + @enderror + +
+ + + Cancel + + + Submit + +
+
+
+ + +
+
+ Confirm rosters + + Review the submitted roster details for both teams before saving them permanently. + +
+ +
+
+ {{ $homeTeamCode }} +
+
+ {{ $awayTeamCode }} +
+
+ +
+
+
+ @foreach ([$homeRosterConfirmation, $awayRosterConfirmation] as $teamConfirmation) +
+ Players + @if ($teamConfirmation['players']->isEmpty()) + No rostered players. + @else +
+ @foreach ($teamConfirmation['players'] as $player) +
+ + @if ($player['is_captain']) + + {{ $player['number'] }} + + @else + {{ $player['number'] }} + @endif + + {{ $player['name'] }} +
+ @endforeach +
+ @endif +
+ @endforeach +
+
+ +
+
+ @foreach ([$homeRosterConfirmation, $awayRosterConfirmation] as $teamConfirmation) +
+ Liberos + @if ($teamConfirmation['liberos']->isEmpty()) + No liberos selected. + @else +
+ @foreach ($teamConfirmation['liberos'] as $libero) +
+ + {{ $libero['number'] }} + + {{ $libero['name'] }} +
+ @endforeach +
+ @endif +
+ @endforeach +
+
+ +
+
+ @foreach ([$homeRosterConfirmation, $awayRosterConfirmation] as $teamConfirmation) +
+ Bench + @if ($teamConfirmation['bench']->isEmpty()) + No bench staff selected. + @else +
+ @foreach ($teamConfirmation['bench'] as $staffMember) +
+ + {{ $staffMember['role'] }} + + {{ $staffMember['name'] }} +
+ @endforeach +
+ @endif +
+ @endforeach +
+
+
+ + @error('rosters') + {{ $message }} + @enderror + +
+ + + Return to roster + + + Submit + +
+
+
+ @endif +
diff --git a/resources/views/livewire/scoreboard.blade.php b/resources/views/livewire/scoreboard.blade.php new file mode 100644 index 0000000..19939ee --- /dev/null +++ b/resources/views/livewire/scoreboard.blade.php @@ -0,0 +1,27 @@ +
+
+ {{ $leftTeamCode }} + Sets + {{ $rightTeamCode }} +
+
+ {{ $leftSets }} + : + {{ $rightSets }} +
+ +
+ {{ $leftTeamCode }} + Points + {{ $rightTeamCode }} +
+
+ {{ $leftPoints }} + : + {{ $rightPoints }} +
+
diff --git a/resources/views/livewire/start-set-submission.blade.php b/resources/views/livewire/start-set-submission.blade.php new file mode 100644 index 0000000..f22db26 --- /dev/null +++ b/resources/views/livewire/start-set-submission.blade.php @@ -0,0 +1,69 @@ +
+ @if ($showSetBreakCountdown) +
+
+ + Next set in {{ $setBreakCountdownLabel }} + +
+ + @error('startSet') + {{ $message }} + @enderror +
+ @elseif ($showStartGameButton) +
+ + Start Game + + + @error('startSet') + {{ $message }} + @enderror +
+ @elseif ($shouldAutoStartSet) +
+ @endif +
diff --git a/resources/views/livewire/team-roster.blade.php b/resources/views/livewire/team-roster.blade.php new file mode 100644 index 0000000..2df35b7 --- /dev/null +++ b/resources/views/livewire/team-roster.blade.php @@ -0,0 +1,457 @@ + diff --git a/resources/views/livewire/toss-result-submission.blade.php b/resources/views/livewire/toss-result-submission.blade.php new file mode 100644 index 0000000..741c882 --- /dev/null +++ b/resources/views/livewire/toss-result-submission.blade.php @@ -0,0 +1,127 @@ +
+ @if ($isBeforeInitialToss) + @if ($hasSubmittedRosters) +
+ + + Submit Toss Result + + + + +
+
+ Submit Toss Result + + Select which side is Team A and which team serves first. + +
+ + + + + + @error('teamA') + {{ $message }} + @enderror + + + + + + @error('serving') + {{ $message }} + @enderror + + @error('submit') + {{ $message }} + @enderror + +
+ + + Cancel + + + Save Toss Result + +
+
+
+
+ @endif + @else + @unless ($hasSubmittedToss) +
+ + + {{ $isFifthSetToss ? 'Submit Fifth Set Toss' : 'Submit Toss Result' }} + + + + +
+
+ {{ $isFifthSetToss ? 'Submit Fifth Set Toss' : 'Submit Toss Result' }} + + {{ $isFifthSetToss + ? 'Select which team starts on the left and which team serves first.' + : 'Select which side is Team A and which team serves first.' }} + +
+ + + + + + @error($isFifthSetToss ? 'left' : 'teamA') + {{ $message }} + @enderror + + + + + + @error('serving') + {{ $message }} + @enderror + + @error('submit') + {{ $message }} + @enderror + +
+ + + Cancel + + + {{ $isFifthSetToss ? 'Save Fifth Set Toss' : 'Save Toss Result' }} + +
+
+
+
+ @endunless + @endif +
diff --git a/routes/web.php b/routes/web.php index 9d02afd..8eb0fe3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,9 @@ view('welcome')); +Route::livewire('/game/{game}', Game::class)->name('game'); diff --git a/tests/Feature/ControlledGameSeederTest.php b/tests/Feature/ControlledGameSeederTest.php new file mode 100644 index 0000000..1eb7d6e --- /dev/null +++ b/tests/Feature/ControlledGameSeederTest.php @@ -0,0 +1,86 @@ +set('game.between_sets_duration', 180); + + $this->seed(ControlledGameSeeder::class); + + $game = Game::query()->with(['homeTeam', 'awayTeam', 'players', 'staff', 'officials'])->sole(); + $homePlayers = $game->players + ->filter(fn (Player $player): bool => $player->roster->team_id === $game->home_team_id) + ->values(); + $awayPlayers = $game->players + ->filter(fn (Player $player): bool => $player->roster->team_id === $game->away_team_id) + ->values(); + $homeStaff = $game->staff + ->filter(fn (Staff $staff): bool => $staff->roster->team_id === $game->home_team_id) + ->values(); + $awayStaff = $game->staff + ->filter(fn (Staff $staff): bool => $staff->roster->team_id === $game->away_team_id) + ->values(); + + expect($game->homeTeam->name)->toBe('Dev Home Team') + ->and($game->awayTeam->name)->toBe('Dev Away Team') + ->and($homePlayers)->toHaveCount(13) + ->and($awayPlayers)->toHaveCount(12); + + $homeRosterNumbers = $homePlayers + ->sortBy(fn (Player $player): int => $player->roster->number) + ->pluck('roster.number') + ->values() + ->all(); + + $awayRosterNumbers = $awayPlayers + ->sortBy(fn (Player $player): int => $player->roster->number) + ->pluck('roster.number') + ->values() + ->all(); + + expect($homeRosterNumbers)->toBe(range(1, 13)) + ->and($awayRosterNumbers)->toBe(range(20, 31)) + ->and($homePlayers->filter(fn (Player $player): bool => $player->roster->is_libero)->count())->toBe(1) + ->and($awayPlayers->filter(fn (Player $player): bool => $player->roster->is_libero)->count())->toBe(0); + + expect($homeStaff)->toHaveCount(5) + ->and($awayStaff)->toHaveCount(3); + + $homeRoleCounts = $homeStaff + ->countBy(fn (Staff $staff): string => $staff->roster->role->value) + ->all(); + + $awayRoleCounts = $awayStaff + ->countBy(fn (Staff $staff): string => $staff->roster->role->value) + ->all(); + + expect($homeRoleCounts[StaffRole::Coach->value] ?? 0)->toBe(1) + ->and($homeRoleCounts[StaffRole::AssistantCoach->value] ?? 0)->toBe(2) + ->and($homeRoleCounts[StaffRole::Doctor->value] ?? 0)->toBe(1) + ->and($homeRoleCounts[StaffRole::Therapist->value] ?? 0)->toBe(1); + + expect($awayRoleCounts[StaffRole::Coach->value] ?? 0)->toBe(1) + ->and($awayRoleCounts[StaffRole::AssistantCoach->value] ?? 0)->toBe(1) + ->and($awayRoleCounts[StaffRole::Doctor->value] ?? 0)->toBe(1) + ->and($awayRoleCounts[StaffRole::Therapist->value] ?? 0)->toBe(0); + + expect($game->officials)->toHaveCount(count(OfficialRole::cases())); +}); + +test('controlled game seeder restores the configured between-sets interval after seeding', function (): void { + config()->set('game.between_sets_duration', 180); + + $this->seed(ControlledGameSeeder::class); + + expect(config('game.between_sets_duration'))->toBe(180); +}); diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php new file mode 100644 index 0000000..ea12fc9 --- /dev/null +++ b/tests/Feature/CourtTest.php @@ -0,0 +1,922 @@ +set('game.between_sets_duration', 0); +}); + +test('court does not show player lists before toss is submitted', function (): void { + $game = gameWithNumberedRosters(); + + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 0])]) + ->assertDontSee('Submit Lineup') + ->assertDontSeeHtml('data-team-roster-number="3"') + ->assertDontSeeHtml('data-team-roster-number="12"') + ->assertDontSeeHtml('data-team-roster-number="2"') + ->assertDontSeeHtml('data-team-roster-number="9"') + ->assertDontSeeHtml('data-misconduct-controls="left"') + ->assertDontSeeHtml('data-misconduct-controls="right"') + ->assertDontSeeHtml('data-delay-controls="left"') + ->assertDontSeeHtml('data-delay-controls="right"') + ->assertDontSee('1 Libero') + ->assertDontSee('20 Keeper') + ->assertDontSee('Anna') + ->assertDontSee('Beth') + ->assertDontSee('Dora') + ->assertDontSee('Etta'); +}); + +test('court shows player lists after toss is submitted', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 1])]) + ->assertSeeInOrder([ + '3', + '12', + '2', + '9', + ]) + ->assertSee('Submit Lineup') + ->assertDontSee('1 Libero') + ->assertDontSee('20 Keeper') + ->assertDontSee('Anna') + ->assertDontSee('Beth') + ->assertDontSee('Dora') + ->assertDontSee('Etta'); +}); + +test('court hides players currently on court from roster lists when lineup is present', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 1, + 'rotation_team_a' => [1 => 3], + 'rotation_team_b' => [1 => 2], + ]), + ]) + ->assertDontSeeHtml('data-team-roster-number="3"') + ->assertSeeHtml('data-team-roster-number="12"') + ->assertDontSeeHtml('data-team-roster-number="2"') + ->assertSeeHtml('data-team-roster-number="9"'); +}); + +test('court swaps team sides in sets two three and four', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 2, 'sets_won_team_a' => 1])]) + ->assertSeeInOrder([ + '2', + '9', + '3', + '12', + ]); + + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 3, 'sets_won_team_a' => 1, 'sets_won_team_b' => 1])]) + ->assertSeeInOrder([ + '3', + '12', + '2', + '9', + ]); + + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 4, 'sets_won_team_a' => 2, 'sets_won_team_b' => 1])]) + ->assertSeeInOrder([ + '2', + '9', + '3', + '12', + ]); +}); + +test('court alternates left and right rosters from set one to set four', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + $setExpectations = [ + 1 => ['set_number' => 1, 'sets_won_team_a' => 0, 'sets_won_team_b' => 0, 'expected' => [ + '3', + '12', + '2', + '9', + ]], + 2 => ['set_number' => 2, 'sets_won_team_a' => 1, 'sets_won_team_b' => 0, 'expected' => [ + '2', + '9', + '3', + '12', + ]], + 3 => ['set_number' => 3, 'sets_won_team_a' => 1, 'sets_won_team_b' => 1, 'expected' => [ + '3', + '12', + '2', + '9', + ]], + 4 => ['set_number' => 4, 'sets_won_team_a' => 2, 'sets_won_team_b' => 1, 'expected' => [ + '2', + '9', + '3', + '12', + ]], + ]; + + foreach ($setExpectations as $state) { + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameStateWithSubmittedLineups([ + 'set_number' => $state['set_number'], + 'sets_won_team_a' => $state['sets_won_team_a'], + 'sets_won_team_b' => $state['sets_won_team_b'], + ]), + ])->assertSeeInOrder($state['expected']); + } +}); + +test('court keeps team a on the left in first and fifth sets regardless of toss side assignment', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Away, TeamAB::TeamA); + + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 1])]) + ->assertSeeInOrder([ + '2', + '9', + '3', + '12', + ]); + + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 5, 'sets_won_team_a' => 2, 'sets_won_team_b' => 2])]) + ->assertSeeInOrder([ + '2', + '9', + '3', + '12', + ]); +}); + +test('court shows lineup position one as bottom left for the left side and top right for the right side', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 1, + 'rotation_team_a' => [1 => 12], + 'rotation_team_b' => [1 => 9], + ]), + ]) + ->assertSeeHtml('data-court-marker="left-team_a-1"') + ->assertSeeHtml('data-court-marker="right-team_b-1"') + ->assertSeeHtml('left-[12%] bottom-[14%]') + ->assertSeeHtml('right-[12%] top-[14%]'); +}); + +test('court position one anchors follow the side after team swap', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 2, + 'sets_won_team_a' => 1, + 'rotation_team_a' => [1 => 12], + 'rotation_team_b' => [1 => 9], + ]), + ]) + ->assertSeeHtml('data-court-marker="left-team_b-1"') + ->assertSeeHtml('data-court-marker="right-team_a-1"') + ->assertSeeHtml('left-[12%] bottom-[14%]') + ->assertSeeHtml('right-[12%] top-[14%]'); +}); + +test('court shows serving team position one outside the court on the left side', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 1, + 'serving_team' => TeamAB::TeamA->value, + 'rotation_team_a' => [1 => 12], + 'rotation_team_b' => [1 => 9], + ]), + ]) + ->assertSeeHtml('data-court-marker="left-team_a-1"') + ->assertSeeHtml('data-court-serving-player="1"') + ->assertSeeHtml('-left-10 bottom-[14%]') + ->assertSeeHtml('data-court-marker="right-team_b-1"') + ->assertSeeHtml('right-[12%] top-[14%]'); +}); + +test('court shows serving team position one outside the court after side swap', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 2, + 'sets_won_team_a' => 1, + 'serving_team' => TeamAB::TeamA->value, + 'rotation_team_a' => [1 => 12], + 'rotation_team_b' => [1 => 9], + ]), + ]) + ->assertSeeHtml('data-court-marker="right-team_a-1"') + ->assertSeeHtml('data-court-serving-player="1"') + ->assertSeeHtml('-right-10 top-[14%]') + ->assertSeeHtml('data-court-marker="left-team_b-1"') + ->assertSeeHtml('left-[12%] bottom-[14%]'); +}); + +test('court keeps the serving marker on the left side after a set swap', function (): void { + $game = gameWithStartedSet(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordLineup(2, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(2, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->assertSeeHtml('data-court-marker="left-team_b-1"') + ->assertSeeHtml('data-court-serving-player="1"') + ->assertSeeHtml('-left-10 bottom-[14%]') + ->assertSeeHtml('data-court-marker="right-team_a-1"') + ->assertSeeHtml('right-[12%] top-[14%]'); +}); + +test('court keeps serving on the left side after swap even when team b won the previous set', function (): void { + $game = gameWithStartedSet(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamB); + } + + $game->recordLineup(2, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(2, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->assertSeeHtml('data-court-marker="left-team_b-1"') + ->assertSeeHtml('data-court-serving-player="1"') + ->assertSeeHtml('-left-10 bottom-[14%]') + ->assertSeeHtml('data-court-marker="right-team_a-1"') + ->assertSeeHtml('right-[12%] top-[14%]'); +}); + +test('court swaps sides as soon as a set ends before the next set starts', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameStateWithSubmittedLineups([ + 'set_number' => 1, + 'sets_won_team_a' => 1, + 'set_in_progress' => false, + ]), + ]) + ->assertSeeInOrder([ + '2', + '9', + '3', + '12', + ]); +}); + +test('court keeps lineup submission order aligned with left and right sides in set one', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 1, + 'set_in_progress' => false, + ]), + ])->assertSeeInOrder([ + 'submit-lineup-team_a', + 'submit-lineup-team_b', + ]); +}); + +test('court swaps lineup submission order as soon as a set ends before the next set starts', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 1, + 'sets_won_team_a' => 1, + 'set_in_progress' => false, + ]), + ])->assertSeeInOrder([ + 'submit-lineup-team_b', + 'submit-lineup-team_a', + ]); +}); + +test('court uses the fifth set toss to place the correct team on the left before lineup submission', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 4, + 'sets_won_team_a' => 2, + 'sets_won_team_b' => 2, + 'fifth_set_left_team' => TeamAB::TeamB->value, + 'serving_team' => TeamAB::TeamB->value, + 'set_in_progress' => false, + ]), + ]) + ->assertSeeInOrder([ + 'submit-lineup-team_b', + 'submit-lineup-team_a', + ]) + ->assertSeeInOrder([ + '2', + '9', + '3', + '12', + ]); +}); + +test('court swaps sides in the fifth set after the 8-point side change', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 5, + 'sets_won_team_a' => 2, + 'sets_won_team_b' => 2, + 'fifth_set_left_team' => TeamAB::TeamB->value, + 'fifth_set_side_swapped' => true, + 'serving_team' => TeamAB::TeamA->value, + 'rotation_team_a' => [1 => 12], + 'rotation_team_b' => [1 => 9], + 'set_in_progress' => true, + ]), + ]) + ->assertSeeHtml('data-court-marker="left-team_b-1"') + ->assertSeeHtml('data-court-marker="right-team_a-1"') + ->assertSeeHtml('data-court-serving-player="1"') + ->assertSeeHtml('-right-10 top-[14%]'); +}); + +test('court renders rally winner controls when set is in progress', function (): void { + $game = Game::factory()->create(); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => false]), + ]) + ->assertDontSee('Winner') + ->assertDontSeeHtml('data-rally-winner-button="team_a"') + ->assertDontSeeHtml('data-rally-winner-button="team_b"'); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => true]), + ]) + ->assertSee('Winner') + ->assertSeeHtml('data-rally-winner-button="team_a"') + ->assertSeeHtml('data-rally-winner-button="team_b"'); +}); + +test('court renders misconduct controls on both sides', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameStateWithSubmittedLineups(['set_number' => 1]), + ]) + ->assertSeeHtml('data-misconduct-controls="left"') + ->assertSeeHtml('data-misconduct-controls="right"') + ->assertSeeHtml('data-misconduct-button="warning"') + ->assertSeeHtml('data-misconduct-button="penalty"') + ->assertSeeHtml('data-misconduct-button="expulsion"') + ->assertSeeHtml('data-misconduct-button="disqualification"') + ->assertSee('Misconduct') + ->assertSeeHtml('aria-label="Minor misconduct"') + ->assertSeeHtml('aria-label="Penalty"') + ->assertSeeHtml('aria-label="Expulsion"') + ->assertSeeHtml('aria-label="Disqualification"') + ->assertSeeHtml('data-delay-controls="left"') + ->assertSeeHtml('data-delay-controls="right"') + ->assertSeeHtml('data-delay-button="delay-warning"') + ->assertSeeHtml('data-delay-button="delay-penalty"') + ->assertSee('Delay') + ->assertSeeHtml('aria-label="Delay warning"') + ->assertSeeHtml('aria-label="Delay penalty"') + ->assertSeeHtml('yellow-card.svg') + ->assertSeeHtml('red-card.svg') + ->assertSeeHtml('yellow-red-card.svg') + ->assertSeeHtml('yellow-red-side-by-side-card.svg') + ->assertDontSeeHtml('Minor misconduct') + ->assertDontSeeHtml('Penalty') + ->assertDontSeeHtml('Expulsion') + ->assertDontSeeHtml('Disqualification') + ->assertDontSeeHtml('Delay warning') + ->assertDontSeeHtml('Delay penalty'); +}); + +test('rally winner controls show button only while a set is in progress and game is not ended', function (): void { + $game = Game::factory()->create(); + + Livewire::test(RallyWinnerControls::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => false]), + 'side' => 'left', + ]) + ->assertDontSee('Winner') + ->assertDontSeeHtml('data-rally-winner-button="team_a"'); + + Livewire::test(RallyWinnerControls::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => true]), + 'side' => 'left', + ]) + ->assertSee('Winner') + ->assertSeeHtml('data-rally-winner-button="team_a"'); + + Livewire::test(RallyWinnerControls::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => true]), + 'side' => 'right', + ]) + ->assertSee('Winner') + ->assertSeeHtml('data-rally-winner-button="team_b"'); + + Livewire::test(RallyWinnerControls::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState(['set_number' => 5, 'set_in_progress' => true, 'game_ended' => true]), + 'side' => 'left', + ]) + ->assertDontSee('Winner') + ->assertDontSeeHtml('data-rally-winner-button="team_a"'); +}); + +test('rally winner controls swap sides as soon as sides swap', function (): void { + $game = Game::factory()->create(); + + $state = gameState([ + 'set_number' => 2, + 'sets_won_team_a' => 1, + 'set_in_progress' => true, + ]); + + Livewire::test(RallyWinnerControls::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $state, + 'side' => 'left', + ]) + ->assertSeeHtml('data-rally-winner-side-team="left-team_b"'); + + Livewire::test(RallyWinnerControls::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $state, + 'side' => 'right', + ]) + ->assertSeeHtml('data-rally-winner-side-team="right-team_a"'); +}); + +test('rally winner controls record rally winner for the selected team and dispatches a refresh event', function (): void { + $game = gameWithStartedSet(); + + Livewire::test(RallyWinnerControls::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => true]), + ]) + ->assertSeeHtml('data-rally-winner-button="team_a"') + ->assertSeeHtml('data-rally-winner-side-team="left-team_a"') + ->call('recordRallyWinner', TeamAB::TeamA->value) + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded'); + + $latestEvent = $game->fresh()->events->last(); + + expect($latestEvent)->not->toBeNull() + ->and($latestEvent->type)->toBe(GameEventType::RallyEnded) + ->and($latestEvent->payload)->toBeInstanceOf(RallyEndedPayload::class) + ->and($latestEvent->payload->team)->toBe(TeamAB::TeamA); +}); + +test('court delay warning button confirms and records a delay warning', function (): void { + $game = gameWithStartedSet(); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->call('requestDelayWarning', TeamAB::TeamA->value) + ->assertSet('pendingDelayWarningTeam', TeamAB::TeamA->value) + ->call('recordPendingDelayWarning') + ->assertSet('pendingDelayWarningTeam', null) + ->assertSet('delaySanctionRecordedTitle', 'Delay warning recorded') + ->assertSee('A delay warning has been recorded for Team A.') + ->assertDontSeeHtml('data-delay-warning-recorded-indicator="left-team_a"') + ->assertDispatched('game-event-recorded'); + + $latestEvent = $game->fresh()->events->last(); + + expect($latestEvent)->not->toBeNull() + ->and($latestEvent->type)->toBe(GameEventType::DelayWarningRecorded) + ->and($latestEvent->payload)->toBeInstanceOf(DelayWarningRecordedPayload::class) + ->and($latestEvent->payload->team)->toBe(TeamAB::TeamA) + ->and($latestEvent->payload->requestType)->toBeNull() + ->and($game->fresh()->stateAt()->delayWarningsTeamA)->toBe(1); +}); + +test('court delay warning button can record a warning between sets', function (): void { + $game = gameWithStartedSet(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $betweenSetsState = $game->fresh()->stateAt(); + + expect($betweenSetsState->setInProgress)->toBeFalse(); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $betweenSetsState, + ]) + ->call('requestDelayWarning', TeamAB::TeamB->value) + ->call('recordPendingDelayWarning') + ->assertSet('delaySanctionRecordedTitle', 'Delay warning recorded') + ->assertSee('A delay warning has been recorded for Team B.') + ->assertDispatched('game-event-recorded'); + + $latestEvent = $game->fresh()->events->last(); + + expect($latestEvent)->not->toBeNull() + ->and($latestEvent->type)->toBe(GameEventType::DelayWarningRecorded) + ->and($latestEvent->payload)->toBeInstanceOf(DelayWarningRecordedPayload::class) + ->and($latestEvent->payload->team)->toBe(TeamAB::TeamB) + ->and($game->fresh()->stateAt()->delayWarningsTeamB)->toBe(1); +}); + +test('court disables the delay warning button when a warning already exists', function (): void { + $game = gameWithStartedSet(); + $game->recordDelayWarning(TeamAB::TeamA); + + $component = Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]); + + expect($component->html()) + ->toMatch('/]*(?:disabled[^>]*data-delay-button="delay-warning"|data-delay-button="delay-warning"[^>]*disabled)[^>]*data-delay-side-team="left-team_a"/') + ->toContain('data-delay-warning-recorded-indicator="left-team_a"') + ->toContain('opacity-60'); +}); + +test('court delay penalty button confirms and records a repeatable delay penalty', function (): void { + $game = gameWithStartedSet(); + $game->recordDelayWarning(TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->call('requestDelayPenalty', TeamAB::TeamA->value) + ->assertSet('pendingDelayPenaltyTeam', TeamAB::TeamA->value) + ->call('recordPendingDelayPenalty') + ->assertSet('pendingDelayPenaltyTeam', null) + ->assertSet('delaySanctionRecordedTitle', 'Delay penalty recorded') + ->assertSee('A delay penalty has been recorded for Team A.') + ->assertDispatched('game-event-recorded'); + + $state = $game->fresh()->stateAt(); + $latestEvent = $game->fresh()->events->last(); + + expect($latestEvent)->not->toBeNull() + ->and($latestEvent->type)->toBe(GameEventType::DelayPenaltyRecorded) + ->and($latestEvent->payload)->toBeInstanceOf(DelayPenaltyRecordedPayload::class) + ->and($latestEvent->payload->team)->toBe(TeamAB::TeamA) + ->and($latestEvent->payload->awardedTeam)->toBe(TeamAB::TeamB) + ->and($latestEvent->payload->requestType)->toBeNull() + ->and($state->scoreTeamB)->toBe(1) + ->and($state->servingTeam)->toBe(TeamAB::TeamB) + ->and($state->delayPenaltiesTeamA)->toBe(1); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->call('requestDelayPenalty', TeamAB::TeamA->value) + ->call('recordPendingDelayPenalty') + ->assertSet('delaySanctionRecordedTitle', 'Delay penalty recorded') + ->assertDispatched('game-event-recorded'); + + expect($game->fresh()->stateAt()->delayPenaltiesTeamA)->toBe(2); + + $component = Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]); + + expect($component->html()) + ->toMatch('/]*disabled=)[^>]*data-delay-button="delay-penalty"[^>]*data-delay-side-team="left-team_a"/') + ->not->toContain('data-delay-penalty-locked-indicator="left-team_a"'); +}); + +test('court disables the delay penalty button with a lock until a warning exists', function (): void { + $game = gameWithStartedSet(); + + $component = Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->assertViewHas('leftDelayPenaltyDisabled', true); + + expect($component->html()) + ->toMatch('/]*(?:disabled[^>]*data-delay-button="delay-penalty"|data-delay-button="delay-penalty"[^>]*disabled)[^>]*data-delay-side-team="left-team_a"/') + ->toContain('data-delay-penalty-locked-indicator="left-team_a"') + ->toContain('data-delay-penalty-locked-icon'); +}); + +test('court misconduct flow shows rostered players and staff for the team', function (): void { + $game = gameWithStartedSet(); + $staff = Staff::factory()->for($game->homeTeam)->create(); + $game->addStaff($staff, StaffRole::Coach); + + $player = $game->homePlayers()->wherePivot('number', 1)->firstOrFail(); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->call('requestMisconduct', TeamAB::TeamA->value, MisconductSanction::Penalty->value) + ->assertSet('pendingMisconductTeam', TeamAB::TeamA->value) + ->assertSet('pendingMisconductSanction', MisconductSanction::Penalty->value) + ->assertViewHas('misconductSubjects', function (array $subjects) use ($player, $staff): bool { + $hasPlayer = collect($subjects['players'])->contains(fn (array $subject): bool => $subject['subject_id'] === $player->getKey() + && $subject['subject_type'] === MisconductSubjectType::Player->value + && $subject['marker'] === '1'); + + $hasStaff = collect($subjects['staff'])->contains(fn (array $subject): bool => $subject['subject_id'] === $staff->getKey() + && $subject['subject_type'] === MisconductSubjectType::Staff->value + && $subject['marker'] === 'C'); + + return $hasPlayer && $hasStaff; + }) + ->assertSeeHtml('data-misconduct-subject-button="player-'.$player->getKey().'"') + ->assertSeeHtml('data-misconduct-subject-button="staff-'.$staff->getKey().'"') + ->assertSeeHtml('rounded-full') + ->assertSeeHtml('bg-blue-600') + ->assertDontSee($player->first_name) + ->assertDontSee($player->last_name) + ->assertDontSee($staff->first_name) + ->assertDontSee($staff->last_name); +}); + +test('court misconduct flow records a sanction against a player after confirmation', function (): void { + $game = gameWithStartedSet(); + $player = $game->homePlayers()->wherePivot('number', 1)->firstOrFail(); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->call('requestMisconduct', TeamAB::TeamA->value, MisconductSanction::Penalty->value) + ->call('selectMisconductSubject', MisconductSubjectType::Player->value, $player->getKey()) + ->assertSet('pendingMisconductSubjectType', MisconductSubjectType::Player->value) + ->assertSet('pendingMisconductSubjectId', $player->getKey()) + ->call('recordPendingMisconduct') + ->assertSet('pendingMisconductTeam', null) + ->assertSet('delaySanctionRecordedTitle', 'Misconduct recorded') + ->assertSee('Penalty has been recorded for 1') + ->assertDispatched('game-event-recorded'); + + $latestEvent = $game->fresh()->events->last(); + + expect($latestEvent)->not->toBeNull() + ->and($latestEvent->type)->toBe(GameEventType::MisconductRecorded) + ->and($latestEvent->payload)->toBeInstanceOf(MisconductRecordedPayload::class) + ->and($latestEvent->payload->team)->toBe(TeamAB::TeamA) + ->and($latestEvent->payload->subjectType)->toBe(MisconductSubjectType::Player) + ->and($latestEvent->payload->subjectId)->toBe($player->getKey()) + ->and($latestEvent->payload->sanction)->toBe(MisconductSanction::Penalty); +}); + +test('court misconduct flow records a sanction against staff after confirmation', function (): void { + $game = gameWithStartedSet(); + $staff = Staff::factory()->for($game->homeTeam)->create(); + $game->addStaff($staff, StaffRole::Coach); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->call('requestMisconduct', TeamAB::TeamA->value, MisconductSanction::Expulsion->value) + ->call('selectMisconductSubject', MisconductSubjectType::Staff->value, $staff->getKey()) + ->assertSet('pendingMisconductSubjectType', MisconductSubjectType::Staff->value) + ->assertSet('pendingMisconductSubjectId', $staff->getKey()) + ->call('recordPendingMisconduct') + ->assertSet('pendingMisconductTeam', null) + ->assertSet('delaySanctionRecordedTitle', 'Misconduct recorded') + ->assertSee('Expulsion has been recorded for C') + ->assertDispatched('game-event-recorded'); + + $latestEvent = $game->fresh()->events->last(); + + expect($latestEvent)->not->toBeNull() + ->and($latestEvent->type)->toBe(GameEventType::MisconductRecorded) + ->and($latestEvent->payload)->toBeInstanceOf(MisconductRecordedPayload::class) + ->and($latestEvent->payload->team)->toBe(TeamAB::TeamA) + ->and($latestEvent->payload->subjectType)->toBe(MisconductSubjectType::Staff) + ->and($latestEvent->payload->subjectId)->toBe($staff->getKey()) + ->and($latestEvent->payload->sanction)->toBe(MisconductSanction::Expulsion); +}); + +test('court disables minor misconduct with a check once it has been recorded for the team', function (): void { + $game = gameWithStartedSet(); + $player = $game->homePlayers()->wherePivot('number', 1)->firstOrFail(); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: MisconductSanction::Warning, + ); + + $component = Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->fresh()->stateAt(), + ]) + ->assertViewHas('leftMinorMisconductDisabled', true); + + expect($component->html()) + ->toMatch('/]*(?:disabled[^>]*data-misconduct-button="warning"|data-misconduct-button="warning"[^>]*disabled)[^>]*data-misconduct-side-team="left-team_a"/') + ->toContain('data-minor-misconduct-recorded-indicator="left-team_a"') + ->toContain('opacity-60'); +}); + +test('court misconduct picker marks people unavailable for same or lower sanctions', function (): void { + $game = gameWithStartedSet(); + $player = $game->homePlayers()->wherePivot('number', 1)->firstOrFail(); + $staff = Staff::factory()->for($game->homeTeam)->create(); + $game->addStaff($staff, StaffRole::Coach); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: MisconductSanction::Penalty, + ); + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Staff, + subjectId: $staff->getKey(), + sanction: MisconductSanction::Expulsion, + ); + + $component = Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->fresh()->stateAt(), + ]) + ->call('requestMisconduct', TeamAB::TeamA->value, MisconductSanction::Penalty->value) + ->assertViewHas('misconductSubjects', function (array $subjects) use ($player, $staff): bool { + $playerUnavailable = collect($subjects['players'])->contains(fn (array $subject): bool => $subject['subject_id'] === $player->getKey() + && $subject['unavailable'] === true + && str_ends_with((string) $subject['unavailable_icon'], '/icons/red-card.svg')); + + $staffUnavailable = collect($subjects['staff'])->contains(fn (array $subject): bool => $subject['subject_id'] === $staff->getKey() + && $subject['unavailable'] === true + && str_ends_with((string) $subject['unavailable_icon'], '/icons/yellow-red-card.svg')); + + return $playerUnavailable && $staffUnavailable; + }); + + expect($component->html()) + ->toContain('data-misconduct-subject-unavailable-indicator="player-'.$player->getKey().'"') + ->toContain('data-misconduct-subject-unavailable-indicator="staff-'.$staff->getKey().'"') + ->toContain('red-card.svg') + ->toContain('yellow-red-card.svg') + ->not->toContain('x-mark'); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->fresh()->stateAt(), + ]) + ->call('requestMisconduct', TeamAB::TeamA->value, MisconductSanction::Expulsion->value) + ->assertViewHas('misconductSubjects', function (array $subjects) use ($player, $staff): bool { + $playerAvailable = collect($subjects['players'])->contains(fn (array $subject): bool => $subject['subject_id'] === $player->getKey() + && $subject['unavailable'] === false); + + $staffUnavailable = collect($subjects['staff'])->contains(fn (array $subject): bool => $subject['subject_id'] === $staff->getKey() + && $subject['unavailable'] === true); + + return $playerAvailable && $staffUnavailable; + }); +}); + +function gameWithNumberedRosters(): Game +{ + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayerOne = Player::factory()->for($homeTeam)->named('Anna', 'Zephyr')->create(); + $homePlayerTwo = Player::factory()->for($homeTeam)->named('Beth', 'Anderson')->create(); + $homeLibero = Player::factory()->for($homeTeam)->named('Cara', 'Libero')->create(); + + $awayPlayerOne = Player::factory()->for($awayTeam)->named('Dora', 'Young')->create(); + $awayPlayerTwo = Player::factory()->for($awayTeam)->named('Etta', 'Baker')->create(); + $awayLibero = Player::factory()->for($awayTeam)->named('Faye', 'Keeper')->create(); + + $game->addPlayer($homePlayerOne, number: 12); + $game->addPlayer($homePlayerTwo, number: 3); + $game->addPlayer($homeLibero, number: 1, isLibero: true); + $game->addPlayer($awayPlayerOne, number: 9); + $game->addPlayer($awayPlayerTwo, number: 2); + $game->addPlayer($awayLibero, number: 20, isLibero: true); + + return $game; +} + +function gameWithStartedSet(): Game +{ + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + $game->recordLineup(1, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(1, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + + return $game; +} + +/** + * @param array $attributes + */ +function gameState(array $attributes): GameState +{ + return GameState::fromAttributes($attributes); +} + +/** + * @param array $attributes + */ +function gameStateWithSubmittedLineups(array $attributes): GameState +{ + return gameState(array_merge([ + 'rotation_team_a' => [1 => 999], + 'rotation_team_b' => [1 => 998], + ], $attributes)); +} diff --git a/tests/Feature/DatabaseSeederTest.php b/tests/Feature/DatabaseSeederTest.php new file mode 100644 index 0000000..4cd4b15 --- /dev/null +++ b/tests/Feature/DatabaseSeederTest.php @@ -0,0 +1,21 @@ +seed(DatabaseSeeder::class); + + $game = Game::query()->with(['players', 'staff', 'officials'])->sole(); + + expect($game->rosters_submitted)->toBeFalse() + ->and($game->players)->toHaveCount(0) + ->and($game->staff)->toHaveCount(0) + ->and($game->officials)->toHaveCount(count(OfficialRole::cases())); +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 0e7bde0..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,7 +0,0 @@ -get('/'); - - $response->assertSuccessful(); -}); diff --git a/tests/Feature/GameComponentStateTest.php b/tests/Feature/GameComponentStateTest.php new file mode 100644 index 0000000..1324e37 --- /dev/null +++ b/tests/Feature/GameComponentStateTest.php @@ -0,0 +1,113 @@ +set('game.between_sets_duration', 0); +}); + +test('game component hydrates state from the passed game', function (): void { + $game = GameModel::factory()->create(); + $game->recordToss(TeamSide::Away, TeamAB::TeamB); + + Livewire::test(Game::class, ['game' => $game]) + ->assertSet('gameId', $game->getKey()) + ->assertSet('gameState', fn (GameState $gameState): bool => $gameState->servingTeam === TeamAB::TeamB + && $gameState->setNumber === 0 + && $gameState->rotationTeamA === [] + && $gameState->rotationTeamB === []); +}); + +test('game component uses the passed game id instead of the latest game', function (): void { + $targetGame = GameModel::factory()->create(); + $targetGame->recordToss(TeamSide::Home, TeamAB::TeamA); + + $latestGame = GameModel::factory()->create(); + $latestGame->recordToss(TeamSide::Away, TeamAB::TeamB); + + Livewire::test(Game::class, ['game' => $targetGame]) + ->assertSet('gameId', $targetGame->getKey()) + ->assertSet('gameState', fn (GameState $gameState): bool => $gameState->servingTeam === TeamAB::TeamA); +}); + +test('game component renders sets and current set points for both teams', function (): void { + $game = GameModel::factory()->create(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + ensureLineupsForSet($game, 1); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + ensureLineupsForSet($game, 2); + $game->recordSetStarted(); + + for ($index = 0; $index < 7; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + for ($index = 0; $index < 3; $index++) { + $game->recordRallyWinner(TeamAB::TeamB); + } + + Livewire::test(Game::class, ['game' => $game]) + ->assertSet('gameState', fn (GameState $gameState): bool => $gameState->setsWonTeamA === 1 + && $gameState->setsWonTeamB === 0 + && $gameState->scoreTeamA === 7 + && $gameState->scoreTeamB === 3) + ->assertSee('Sets') + ->assertSee('Points') + ->assertSeeInOrder([ + 'Sets', + '1', + '0', + 'Points', + '7', + '3', + ]); +}); + +function ensureLineupsForSet(GameModel $game, int $set): void +{ + if ($game->players()->count() === 0) { + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } + } + + $game->recordLineup($set, TeamAB::TeamA, lineupPositions(1)); + $game->recordLineup($set, TeamAB::TeamB, lineupPositions(11)); +} + +/** + * @return array + */ +function lineupPositions(int $start): array +{ + return [ + 1 => $start, + 2 => $start + 1, + 3 => $start + 2, + 4 => $start + 3, + 5 => $start + 4, + 6 => $start + 5, + ]; +} diff --git a/tests/Feature/GameEventReactorTest.php b/tests/Feature/GameEventReactorTest.php index 014e078..fc631f6 100644 --- a/tests/Feature/GameEventReactorTest.php +++ b/tests/Feature/GameEventReactorTest.php @@ -4,17 +4,24 @@ use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Models\Game; +use App\Models\Player; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); +beforeEach(function (): void { + config()->set('game.between_sets_duration', 0); +}); + test('in sets one to four the set auto-ends at 25 points with a two-point lead', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); + ensureRostersForSetStart($game); + submitReactorLineupsForSet($game, 1); $game->recordSetStarted(); for ($i = 0; $i < 24; $i++) { @@ -42,8 +49,10 @@ $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); + ensureRostersForSetStart($game); foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $winner) { + submitReactorLineupsForSet($game, $game->stateAt()->setNumber + 1); $game->recordSetStarted(); for ($i = 0; $i < 25; $i++) { @@ -51,6 +60,8 @@ } } + $game->recordToss(TeamSide::Home, TeamAB::TeamA, TeamAB::TeamA); + submitReactorLineupsForSet($game, 5); $game->recordSetStarted(); // 5th set for ($i = 0; $i < 14; $i++) { @@ -72,3 +83,37 @@ ->and($state->setsWonTeamA)->toBe(3) ->and($state->setsWonTeamB)->toBe(2); }); + +function ensureRostersForSetStart(Game $game): void +{ + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } +} + +function submitReactorLineupsForSet(Game $game, int $set): void +{ + $game->recordLineup($set, TeamAB::TeamA, reactorLineupForSet(1)); + $game->recordLineup($set, TeamAB::TeamB, reactorLineupForSet(11)); +} + +/** + * @return array + */ +function reactorLineupForSet(int $start): array +{ + return [ + 1 => $start, + 2 => $start + 1, + 3 => $start + 2, + 4 => $start + 3, + 5 => $start + 4, + 6 => $start + 5, + ]; +} diff --git a/tests/Feature/GameEventTest.php b/tests/Feature/GameEventTest.php index 35125f5..13cd008 100644 --- a/tests/Feature/GameEventTest.php +++ b/tests/Feature/GameEventTest.php @@ -1,10 +1,19 @@ set('game.between_sets_duration', 0); +}); + function prepareActiveSet(Game $game): void { $game->recordToss(TeamSide::Home, TeamAB::TeamA); + ensureStartingLineupRoster($game); + submitLineupsForSet($game, 1); $game->recordSetStarted(); } @@ -33,6 +51,44 @@ function winSet(Game $game, TeamAB $winner): void } } +/** + * @return array + */ +function lineupWithRosterNumbers(int $start = 1): array +{ + return [ + 1 => $start, + 2 => $start + 1, + 3 => $start + 2, + 4 => $start + 3, + 5 => $start + 4, + 6 => $start + 5, + ]; +} + +function ensureStartingLineupRoster(Game $game): void +{ + if ($game->players()->count() > 0) { + return; + } + + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } +} + +function submitLineupsForSet(Game $game, int $set): void +{ + $game->recordLineup($set, TeamAB::TeamA, lineupWithRosterNumbers(1)); + $game->recordLineup($set, TeamAB::TeamB, lineupWithRosterNumbers(11)); +} + test('a toss can be recorded with the correct type and payload', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); @@ -46,9 +102,87 @@ function winSet(Game $game, TeamAB $winner): void expect($event->type)->toBe(GameEventType::TossCompleted) ->and($event->payload)->toBeInstanceOf(TossCompletedPayload::class) ->and($event->payload->teamA)->toBe(TeamSide::Home) + ->and($event->payload->leftTeam)->toBe(TeamAB::TeamA) ->and($event->payload->serving)->toBe(TeamAB::TeamA); }); +test('a fifth set toss keeps team a and team b fixed while changing the left side', function (): void { + $game = Game::factory()->create(); + + ensureStartingLineupRoster($game); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + submitLineupsForSet($game, $game->stateAt()->setNumber + 1); + $game->recordSetStarted(); + winSet($game, $setWinner); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamB, TeamAB::TeamB); + + $event = $game->events->last(); + + expect($event->payload->teamA)->toBe(TeamSide::Home) + ->and($event->payload->leftTeam)->toBe(TeamAB::TeamB) + ->and($event->payload->serving)->toBe(TeamAB::TeamB); +}); + +test('court sides can be swapped once a team reaches 8 points in the fifth set', function (): void { + $game = Game::factory()->create(); + + ensureStartingLineupRoster($game); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + submitLineupsForSet($game, $game->stateAt()->setNumber + 1); + $game->recordSetStarted(); + winSet($game, $setWinner); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamB, TeamAB::TeamA); + submitLineupsForSet($game, 5); + $game->recordSetStarted(); + + for ($index = 0; $index < 8; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordCourtSidesSwapped(); + + $event = $game->fresh()->events->last(); + $state = $game->fresh()->stateAt(); + + expect($event->type)->toBe(GameEventType::CourtSidesSwapped) + ->and($event->payload)->toBeInstanceOf(CourtSidesSwappedPayload::class) + ->and($state->fifthSetLeftTeam)->toBe(TeamAB::TeamB) + ->and($state->fifthSetSideSwapped)->toBeTrue() + ->and($state->servingTeam)->toBe(TeamAB::TeamA); +}); + +test('court sides cannot be swapped before a team reaches 8 points in the fifth set', function (): void { + $game = Game::factory()->create(); + + ensureStartingLineupRoster($game); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + submitLineupsForSet($game, $game->stateAt()->setNumber + 1); + $game->recordSetStarted(); + winSet($game, $setWinner); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamB, TeamAB::TeamA); + submitLineupsForSet($game, 5); + $game->recordSetStarted(); + + for ($index = 0; $index < 7; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + expect(fn () => $game->recordCourtSidesSwapped()) + ->toThrow(InvalidGameEventTransition::class, 'Court sides can only be swapped once a team reaches 8 points in the fifth set.'); +}); + test('team b is derived as the other team when team a is the away team', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); @@ -65,18 +199,17 @@ function winSet(Game $game, TeamAB $winner): void $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + $positions = lineupWithRosterNumbers(); $players = Player::factory()->for($homeTeam)->count(6)->create(); - $positions = $players->mapWithKeys(fn (Player $player, int $index) => [$index + 1 => $player->getKey()])->all(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - $game->recordSetStarted(); foreach ($players as $i => $player) { $game->addPlayer($player, number: $i + 1); } $game->recordLineup(1, TeamAB::TeamA, $positions); - expect($game->events)->toHaveCount(3); + expect($game->events)->toHaveCount(2); $event = $game->events->last(); expect($event->type)->toBe(GameEventType::LineupSubmitted) @@ -91,11 +224,10 @@ function winSet(Game $game, TeamAB $winner): void $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + $positions = lineupWithRosterNumbers(); $players = Player::factory()->for($homeTeam)->count(6)->create(); - $positions = $players->mapWithKeys(fn (Player $player, int $index) => [$index + 1 => $player->getKey()])->all(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - $game->recordSetStarted(); foreach ($players as $i => $player) { $game->addPlayer($player, number: $i + 1); } @@ -115,7 +247,7 @@ function winSet(Game $game, TeamAB $winner): void $event = $game->events->first(); $event->payload = new TossCompletedPayload(teamA: TeamSide::Away, serving: TeamAB::TeamB); - expect(fn () => $event->save())->toThrow(\LogicException::class); + expect(fn () => $event->save())->toThrow(LogicException::class); }); test('the type attribute is cast to a GameEventType enum', function (): void { @@ -149,17 +281,22 @@ function winSet(Game $game, TeamAB $winner): void $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - $game->recordSetStarted(); $players = Player::factory()->for($homeTeam)->count(5)->create(); foreach ($players as $i => $player) { $game->addPlayer($player, number: $i + 1); } - $positions = $players->mapWithKeys(fn (Player $player, int $index) => [$index + 1 => $player->getKey()])->all(); + $positions = [ + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + ]; expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(\InvalidArgumentException::class, 'A lineup must have exactly 6 positions.'); + ->toThrow(InvalidArgumentException::class, 'A lineup must have exactly 6 positions.'); }); test('a lineup with 0-based keys throws an InvalidArgumentException', function (): void { @@ -168,37 +305,41 @@ function winSet(Game $game, TeamAB $winner): void $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - $game->recordSetStarted(); $players = Player::factory()->for($homeTeam)->count(6)->create(); foreach ($players as $i => $player) { $game->addPlayer($player, number: $i + 1); } - $positions = $players->mapWithKeys(fn (Player $player, int $index) => [$index => $player->getKey()])->all(); + $positions = [ + 0 => 1, + 1 => 2, + 2 => 3, + 3 => 4, + 4 => 5, + 5 => 6, + ]; expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(\InvalidArgumentException::class, 'Lineup positions must be keyed 1 through 6.'); + ->toThrow(InvalidArgumentException::class, 'Lineup positions must be keyed 1 through 6.'); }); -test('a lineup with a duplicate player ID throws an InvalidArgumentException', function (): void { +test('a lineup with a duplicate roster number throws an InvalidArgumentException', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - $game->recordSetStarted(); $players = Player::factory()->for($homeTeam)->count(6)->create(); foreach ($players as $i => $player) { $game->addPlayer($player, number: $i + 1); } - $firstPlayerId = $players->first()->getKey(); - $positions = [1 => $firstPlayerId, 2 => $firstPlayerId, 3 => $players[2]->getKey(), 4 => $players[3]->getKey(), 5 => $players[4]->getKey(), 6 => $players[5]->getKey()]; + $positions = [1 => 1, 2 => 1, 3 => 3, 4 => 4, 5 => 5, 6 => 6]; expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(\InvalidArgumentException::class, 'All 6 lineup positions must have different players.'); + ->toThrow(InvalidArgumentException::class, 'All 6 lineup positions must have different roster numbers.'); }); test('a lineup submitted before the toss is rejected', function (): void { @@ -206,13 +347,65 @@ function winSet(Game $game, TeamAB $winner): void $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - $players = Player::factory()->for($homeTeam)->count(6)->create(); - $positions = $players->mapWithKeys(fn (Player $player, int $index) => [$index + 1 => $player->getKey()])->all(); + $positions = lineupWithRosterNumbers(); expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) ->toThrow(InvalidGameEventTransition::class, 'A lineup cannot be submitted before the toss has been recorded.'); }); +test('a lineup cannot be submitted after the set has started', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($homePlayers as $i => $player) { + $game->addPlayer($player, number: $i + 1); + } + + $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); + foreach ($awayPlayers as $i => $player) { + $game->addPlayer($player, number: $i + 11); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + submitLineupsForSet($game, 1); + $game->recordSetStarted(); + + $positions = lineupWithRosterNumbers(); + + expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) + ->toThrow(InvalidGameEventTransition::class, 'A lineup can only be submitted before the set starts.'); +}); + +test('a lineup for the next set can be submitted after the previous set ends', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($homePlayers as $i => $player) { + $game->addPlayer($player, number: $i + 1); + } + + $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); + foreach ($awayPlayers as $i => $player) { + $game->addPlayer($player, number: $i + 11); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + submitLineupsForSet($game, 1); + $game->recordSetStarted(); + winSet($game, TeamAB::TeamA); + + $positions = lineupWithRosterNumbers(); + $game->recordLineup(2, TeamAB::TeamA, $positions); + + $event = $game->events->last(); + expect($event->type)->toBe(GameEventType::LineupSubmitted) + ->and($event->payload->set)->toBe(2); +}); + test('a rally ended event can be recorded with the correct type and payload', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); @@ -242,23 +435,46 @@ function winSet(Game $game, TeamAB $winner): void 'team B' => [TeamAB::TeamB], ]); -test('a lineup with a player not on the team roster throws an InvalidArgumentException', function (): void { +test('a lineup with a roster number not on the team roster throws an InvalidArgumentException', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - $game->recordSetStarted(); + + $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($homePlayers as $i => $player) { + $game->addPlayer($player, number: $i + 1); + } $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); foreach ($awayPlayers as $i => $player) { + $game->addPlayer($player, number: $i + 11); + } + + $positions = lineupWithRosterNumbers(start: 11); + + expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) + ->toThrow(InvalidArgumentException::class, 'is not on the non-libero roster for the specified team.'); +}); + +test('a lineup with non-positive roster numbers throws an InvalidArgumentException', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $players = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($players as $i => $player) { $game->addPlayer($player, number: $i + 1); } - $positions = $awayPlayers->mapWithKeys(fn (Player $player, int $index) => [$index + 1 => $player->getKey()])->all(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + $positions = lineupWithRosterNumbers(); + $positions[1] = 0; expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(\InvalidArgumentException::class, 'is not on the roster for the specified team.'); + ->toThrow(InvalidArgumentException::class, 'must contain a positive roster number.'); }); test('a substitution can be recorded with the correct type and payload', function (): void { @@ -321,15 +537,408 @@ function winSet(Game $game, TeamAB $winner): void 'team B' => [TeamAB::TeamB], ]); +test('an improper request can be recorded with the correct type and payload', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + + $event = $game->events->last(); + expect($event->type)->toBe(GameEventType::ImproperRequestRecorded) + ->and($event->payload)->toBeInstanceOf(ImproperRequestRecordedPayload::class) + ->and($event->payload->team)->toBe(TeamAB::TeamA) + ->and($event->payload->requestType)->toBe(ImproperRequestType::Timeout); +}); + +test('an improper substitution request stores the request type', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Substitution); + + $event = $game->events->last(); + expect($event->type)->toBe(GameEventType::ImproperRequestRecorded) + ->and($event->payload)->toBeInstanceOf(ImproperRequestRecordedPayload::class) + ->and($event->payload->team)->toBe(TeamAB::TeamA) + ->and($event->payload->requestType)->toBe(ImproperRequestType::Substitution); +}); + +test('a second improper request records a delay warning', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Substitution); + + $event = $game->events->last(); + expect($event->type)->toBe(GameEventType::DelayWarningRecorded) + ->and($event->payload)->toBeInstanceOf(DelayWarningRecordedPayload::class) + ->and($event->payload->team)->toBe(TeamAB::TeamA) + ->and($event->payload->requestType)->toBe(ImproperRequestType::Substitution) + ->and($game->fresh()->stateAt()->improperRequestsTeamA)->toBe(1) + ->and($game->fresh()->stateAt()->delayWarningsTeamA)->toBe(1); +}); + +test('a third improper request records a delay penalty and awards the rally to the opponent', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Substitution); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + + $event = $game->events->last(); + $state = $game->fresh()->stateAt(); + + expect($event->type)->toBe(GameEventType::DelayPenaltyRecorded) + ->and($event->payload)->toBeInstanceOf(DelayPenaltyRecordedPayload::class) + ->and($event->payload->team)->toBe(TeamAB::TeamA) + ->and($event->payload->awardedTeam)->toBe(TeamAB::TeamB) + ->and($event->payload->requestType)->toBe(ImproperRequestType::Timeout) + ->and($state->scoreTeamB)->toBe(1) + ->and($state->servingTeam)->toBe(TeamAB::TeamB) + ->and($state->rotationTeamB[1])->toBe(12) + ->and($state->delayPenaltiesTeamA)->toBe(1); +}); + +test('a delay penalty awards only a point when the opponent is already serving', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->recordImproperRequest(TeamAB::TeamB, ImproperRequestType::Timeout); + $game->recordImproperRequest(TeamAB::TeamB, ImproperRequestType::Substitution); + $game->recordImproperRequest(TeamAB::TeamB, ImproperRequestType::Timeout); + + $state = $game->fresh()->stateAt(); + + expect($state->scoreTeamA)->toBe(1) + ->and($state->servingTeam)->toBe(TeamAB::TeamA) + ->and($state->rotationTeamA[1])->toBe(1) + ->and($state->delayPenaltiesTeamB)->toBe(1); +}); + +test('a manual delay penalty can be recorded repeatedly and awards the opponent', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->recordDelayPenalty(TeamAB::TeamA); + $game->recordDelayPenalty(TeamAB::TeamA); + + $event = $game->events->last(); + $state = $game->fresh()->stateAt(); + + expect($event->type)->toBe(GameEventType::DelayPenaltyRecorded) + ->and($event->payload)->toBeInstanceOf(DelayPenaltyRecordedPayload::class) + ->and($event->payload->team)->toBe(TeamAB::TeamA) + ->and($event->payload->awardedTeam)->toBe(TeamAB::TeamB) + ->and($event->payload->requestType)->toBeNull() + ->and($state->scoreTeamB)->toBe(2) + ->and($state->servingTeam)->toBe(TeamAB::TeamB) + ->and($state->delayPenaltiesTeamA)->toBe(2); +}); + +test('improper requests do not reset when a set ends', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + + winSet($game, TeamAB::TeamA); + + $state = $game->fresh()->stateAt(); + expect($state->improperRequestsTeamA)->toBe(1) + ->and($state->delayWarningsTeamA)->toBe(0); +}); + +test('misconduct can be recorded for a player with the correct type and payload', function (MisconductSanction $sanction): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $player = $game->homePlayers()->firstOrFail(); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: $sanction, + ); + + $event = $game->events->last(); + expect($event->type)->toBe(GameEventType::MisconductRecorded) + ->and($event->payload)->toBeInstanceOf(MisconductRecordedPayload::class) + ->and($event->payload->team)->toBe(TeamAB::TeamA) + ->and($event->payload->subjectType)->toBe(MisconductSubjectType::Player) + ->and($event->payload->subjectId)->toBe($player->getKey()) + ->and($event->payload->sanction)->toBe($sanction); +})->with([ + 'warning' => [MisconductSanction::Warning], + 'penalty' => [MisconductSanction::Penalty], + 'expulsion' => [MisconductSanction::Expulsion], + 'disqualification' => [MisconductSanction::Disqualification], +]); + +test('misconduct can be recorded for staff', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $staff = Staff::factory()->for($homeTeam)->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->addStaff($staff, StaffRole::Coach); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Staff, + subjectId: $staff->getKey(), + sanction: MisconductSanction::Expulsion, + ); + + $event = $game->events->last(); + expect($event->type)->toBe(GameEventType::MisconductRecorded) + ->and($event->payload)->toBeInstanceOf(MisconductRecordedPayload::class) + ->and($event->payload->subjectType)->toBe(MisconductSubjectType::Staff) + ->and($event->payload->subjectId)->toBe($staff->getKey()) + ->and($event->payload->sanction)->toBe(MisconductSanction::Expulsion); +}); + +test('a misconduct penalty awards a point and service to the other team', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $player = $game->homePlayers()->firstOrFail(); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: MisconductSanction::Penalty, + ); + + $state = $game->fresh()->stateAt(); + + expect($state->misconductPenaltiesTeamA)->toBe(1) + ->and($state->scoreTeamB)->toBe(1) + ->and($state->servingTeam)->toBe(TeamAB::TeamB) + ->and($state->rotationTeamB[1])->toBe(12); +}); + +test('misconduct sanctions other than penalty do not award a point', function (MisconductSanction $sanction): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $player = $game->homePlayers()->firstOrFail(); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: $sanction, + ); + + $state = $game->fresh()->stateAt(); + + expect($state->scoreTeamA)->toBe(0) + ->and($state->scoreTeamB)->toBe(0) + ->and($state->servingTeam)->toBe(TeamAB::TeamA) + ->and($state->rotationTeamB[1])->toBe(11); +})->with([ + 'warning' => [MisconductSanction::Warning], + 'expulsion' => [MisconductSanction::Expulsion], + 'disqualification' => [MisconductSanction::Disqualification], +]); + +test('a misconduct penalty can end a set', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $player = $game->homePlayers()->firstOrFail(); + + for ($index = 0; $index < 24; $index++) { + $game->recordRallyWinner(TeamAB::TeamB); + } + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: MisconductSanction::Penalty, + ); + + $state = $game->fresh()->stateAt(); + + expect($state->setsWonTeamB)->toBe(1) + ->and($state->setInProgress)->toBeFalse() + ->and($state->scoreTeamA)->toBe(0) + ->and($state->scoreTeamB)->toBe(0); +}); + +test('misconduct can be recorded before the match starts', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + $player = Player::factory()->for($homeTeam)->create(); + + $game->addPlayer($player, number: 7); + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: MisconductSanction::Warning, + ); + + $event = $game->events->last(); + + expect($event->type)->toBe(GameEventType::MisconductRecorded) + ->and($event->payload)->toBeInstanceOf(MisconductRecordedPayload::class) + ->and($event->payload->team)->toBe(TeamAB::TeamA) + ->and($event->payload->subjectType)->toBe(MisconductSubjectType::Player) + ->and($event->payload->subjectId)->toBe($player->getKey()) + ->and($event->payload->sanction)->toBe(MisconductSanction::Warning); +}); + +test('minor misconduct can only be recorded once per team in a game', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + $firstPlayer = Player::factory()->for($homeTeam)->create(); + $secondPlayer = Player::factory()->for($homeTeam)->create(); + + $game->addPlayer($firstPlayer, number: 7); + $game->addPlayer($secondPlayer, number: 8); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $firstPlayer->getKey(), + sanction: MisconductSanction::Warning, + ); + + expect(fn () => $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $secondPlayer->getKey(), + sanction: MisconductSanction::Warning, + ))->toThrow(InvalidGameEventTransition::class, 'This team already has a minor misconduct warning.'); +}); + +test('a person cannot receive the same or a lower misconduct sanction', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + $player = Player::factory()->for($homeTeam)->create(); + $staff = Staff::factory()->for($homeTeam)->create(); + + $game->addPlayer($player, number: 7); + $game->addStaff($staff, StaffRole::Coach); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: MisconductSanction::Penalty, + ); + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Staff, + subjectId: $staff->getKey(), + sanction: MisconductSanction::Expulsion, + ); + + expect(fn () => $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: MisconductSanction::Penalty, + ))->toThrow(InvalidGameEventTransition::class, 'This person already has the same or a higher misconduct sanction.'); + + expect(fn () => $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Staff, + subjectId: $staff->getKey(), + sanction: MisconductSanction::Penalty, + ))->toThrow(InvalidGameEventTransition::class, 'This person already has the same or a higher misconduct sanction.'); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: MisconductSanction::Expulsion, + ); + + expect($game->fresh()->stateAt()->misconductExpulsionsTeamA)->toBe(2); +}); + +test('misconduct counts do not reset when a set ends', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $player = $game->homePlayers()->firstOrFail(); + + $game->recordMisconduct(TeamAB::TeamA, MisconductSubjectType::Player, $player->getKey(), MisconductSanction::Warning); + $game->recordMisconduct(TeamAB::TeamA, MisconductSubjectType::Player, $player->getKey(), MisconductSanction::Penalty); + $game->recordMisconduct(TeamAB::TeamA, MisconductSubjectType::Player, $player->getKey(), MisconductSanction::Expulsion); + $game->recordMisconduct(TeamAB::TeamA, MisconductSubjectType::Player, $player->getKey(), MisconductSanction::Disqualification); + + winSet($game, TeamAB::TeamA); + + $state = $game->fresh()->stateAt(); + expect($state->misconductWarningsTeamA)->toBe(1) + ->and($state->misconductPenaltiesTeamA)->toBe(1) + ->and($state->misconductExpulsionsTeamA)->toBe(1) + ->and($state->misconductDisqualificationsTeamA)->toBe(1); +}); + +test('timeout counts reset to zero when a set ends', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordTimeOut(TeamAB::TeamB); + + winSet($game, TeamAB::TeamA); + + $state = $game->fresh()->stateAt(); + expect($state->timeoutsTeamA)->toBe(0) + ->and($state->timeoutsTeamB)->toBe(0); +}); + test('a set started event can be recorded with the correct type and empty payload', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); + ensureStartingLineupRoster($game); + submitLineupsForSet($game, 1); $game->recordSetStarted(); - expect($game->events)->toHaveCount(2); + expect($game->events)->toHaveCount(4); $event = $game->events->last(); expect($event->type)->toBe(GameEventType::SetStarted) @@ -357,7 +966,9 @@ function winSet(Game $game, TeamAB $winner): void $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); + ensureStartingLineupRoster($game); for ($set = 0; $set < 3; $set++) { + submitLineupsForSet($game, $set + 1); $game->recordSetStarted(); winSet($game, TeamAB::TeamA); } @@ -377,6 +988,19 @@ function winSet(Game $game, TeamAB $winner): void ->toThrow(InvalidGameEventTransition::class, 'A set cannot start before the toss has been recorded.'); }); +test('a set cannot start until both lineups are submitted for the upcoming set', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + ensureStartingLineupRoster($game); + $game->recordLineup(1, TeamAB::TeamA, lineupWithRosterNumbers(1)); + + expect(fn () => $game->recordSetStarted()) + ->toThrow(InvalidGameEventTransition::class, 'Both team lineups must be submitted before starting the set.'); +}); + test('a set cannot end before score reaches 25 with a two-point advantage', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); @@ -391,15 +1015,64 @@ function winSet(Game $game, TeamAB $winner): void ->toThrow(InvalidGameEventTransition::class, 'A set can only end when a team has at least 25 points with a 2-point advantage.'); }); +test('the deciding set cannot end before 15 points with a two-point advantage', function (): void { + $game = Game::factory()->create(); + createSnapshotForSetScore($game, setNumber: 5, scoreTeamA: 14, scoreTeamB: 12); + + expect(fn () => app(GameEventRuleValidator::class)->assertCanRecordSetEnded($game)) + ->toThrow(InvalidGameEventTransition::class, 'A set can only end when a team has at least 15 points with a 2-point advantage.'); +}); + +test('the deciding set can end at 15 points with a two-point advantage', function (): void { + $game = Game::factory()->create(); + createSnapshotForSetScore($game, setNumber: 5, scoreTeamA: 15, scoreTeamB: 13); + + app(GameEventRuleValidator::class)->assertCanRecordSetEnded($game); + + expect(true)->toBeTrue(); +}); + test('a game cannot end before one team has won three sets', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); + ensureStartingLineupRoster($game); + submitLineupsForSet($game, 1); $game->recordSetStarted(); winSet($game, TeamAB::TeamA); expect(fn () => $game->recordGameEnded()) ->toThrow(InvalidGameEventTransition::class, 'A game can only end after one team has won three sets.'); }); + +function createSnapshotForSetScore(Game $game, int $setNumber, int $scoreTeamA, int $scoreTeamB): void +{ + $event = GameEvent::withoutEvents(fn (): GameEvent => GameEvent::query()->create([ + 'game_id' => $game->getKey(), + 'type' => GameEventType::SetStarted, + 'payload' => new SetStartedPayload, + 'created_at' => now(), + ])); + + GameStateSnapshot::query()->create([ + 'game_id' => $game->getKey(), + 'game_event_id' => $event->getKey(), + 'set_number' => $setNumber, + 'score_team_a' => $scoreTeamA, + 'score_team_b' => $scoreTeamB, + 'sets_won_team_a' => 0, + 'sets_won_team_b' => 0, + 'timeouts_team_a' => 0, + 'timeouts_team_b' => 0, + 'substitutions_team_a' => 0, + 'substitutions_team_b' => 0, + 'serving_team' => TeamAB::TeamA->value, + 'rotation_team_a' => [], + 'rotation_team_b' => [], + 'set_in_progress' => true, + 'game_ended' => false, + 'created_at' => now(), + ]); +} diff --git a/tests/Feature/GamePageTest.php b/tests/Feature/GamePageTest.php new file mode 100644 index 0000000..ac1b872 --- /dev/null +++ b/tests/Feature/GamePageTest.php @@ -0,0 +1,118 @@ +set('game.between_sets_duration', 0); +}); + +test('the game page returns a successful response and renders the fixed canvas with the court', function (): void { + $game = GameModel::factory()->create(); + + $response = $this->get(route('game', ['game' => $game])); + + $response->assertSuccessful() + ->assertSee('id="game-canvas"', false) + ->assertSee('bg-sky-100') + ->assertSee('id="volleyball-court"', false) + ->assertSee('Submit rosters') + ->assertDontSee('Submit Toss Result') + ->assertDontSee('data-scoreboard', false) + ->assertDontSee('Submit Lineup'); +}); + +test('the game livewire component renders the court component', function (): void { + $game = GameModel::factory()->create(); + + Livewire::test(Game::class, ['game' => $game]) + ->assertSeeHtml('id="game-canvas"') + ->assertSeeHtml('id="volleyball-court"') + ->assertSee('Submit rosters') + ->assertDontSeeHtml('data-scoreboard'); +}); + +test('the game page renders the start set button when both lineups are submitted for the upcoming set', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = GameModel::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + $game->recordLineup(1, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(1, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + + $response = $this->get(route('game', ['game' => $game])); + + $response->assertSuccessful() + ->assertSee('Start Game'); +}); + +test('the game component prompts for the fifth set side change at 8 points and swaps sides when dismissed', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = GameModel::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $winner) { + $set = $game->stateAt()->setNumber + 1; + $game->recordLineup($set, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup($set, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner($winner); + } + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA, TeamAB::TeamA); + $game->recordLineup(5, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(5, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + + for ($index = 0; $index < 8; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + Livewire::test(Game::class, ['game' => $game->fresh()]) + ->assertSet('showFifthSetSideChangePrompt', true) + ->assertSee('Teams to change court') + ->assertSeeHtml('x-on:keydown.escape.window.capture.prevent.stop=""') + ->assertSeeHtml('x-on:cancel.prevent.stop=""') + ->call('acknowledgeFifthSetSideChange') + ->assertDispatched('game-event-recorded') + ->assertSet('showFifthSetSideChangePrompt', false); + + expect($game->fresh()->stateAt()->fifthSetLeftTeam)->toBe(TeamAB::TeamB) + ->and($game->fresh()->stateAt()->servingTeam)->toBe(TeamAB::TeamA); +}); diff --git a/tests/Feature/GameRosterTest.php b/tests/Feature/GameRosterTest.php index 4a30d93..ef8561f 100644 --- a/tests/Feature/GameRosterTest.php +++ b/tests/Feature/GameRosterTest.php @@ -34,9 +34,9 @@ test('a game has match staff', function (): void { $game = Game::factory()->create(); $team = Team::factory()->create(); - $staff = Staff::factory()->for($team)->create(); + $staff = Staff::factory()->for($team)->asCoach()->create(); - $game->addStaff($staff, StaffRole::Coach); + $game->addStaff($staff); expect($game->staff)->toHaveCount(1) ->and($game->staff->first()->getKey())->toBe($staff->getKey()) diff --git a/tests/Feature/GameStateProjectionTest.php b/tests/Feature/GameStateProjectionTest.php index 9bf483d..fddf717 100644 --- a/tests/Feature/GameStateProjectionTest.php +++ b/tests/Feature/GameStateProjectionTest.php @@ -1,10 +1,15 @@ set('game.between_sets_duration', 0); +}); + test('state snapshots are projected as game events are recorded', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); @@ -28,26 +37,36 @@ $game->addPlayer($player, number: $index + 1); } - $homeLineup = $homePlayers - ->take(6) - ->mapWithKeys(fn (Player $player, int $index) => [$index + 1 => $player->getKey()]) - ->all(); + $homeLineup = [ + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + 6 => 6, + ]; - $awayLineup = $awayPlayers - ->mapWithKeys(fn (Player $player, int $index) => [$index + 1 => $player->getKey()]) - ->all(); + $awayLineup = [ + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + 6 => 6, + ]; $game->recordToss(TeamSide::Home, TeamAB::TeamA); - $game->recordSetStarted(); $game->recordLineup(1, TeamAB::TeamA, $homeLineup); $game->recordLineup(1, TeamAB::TeamB, $awayLineup); + $game->recordSetStarted(); $game->recordRallyWinner(TeamAB::TeamA); $game->recordRallyWinner(TeamAB::TeamB); $game->recordTimeOut(TeamAB::TeamB); + $game->recordImproperRequest(TeamAB::TeamB, ImproperRequestType::Timeout); $game->recordSubstitution( TeamAB::TeamA, - playerOut: $homePlayers->first()->getKey(), - playerIn: $homePlayers->last()->getKey(), + playerOut: 1, + playerIn: 7, ); /** @var GameState $state */ @@ -62,18 +81,23 @@ ->and($state->timeoutsTeamB)->toBe(1) ->and($state->substitutionsTeamA)->toBe(1) ->and($state->substitutionsTeamB)->toBe(0) + ->and($state->improperRequestsTeamA)->toBe(0) + ->and($state->improperRequestsTeamB)->toBe(1) + ->and($state->teamASide)->toBe(TeamSide::Home) ->and($state->servingTeam)->toBe(TeamAB::TeamB) - ->and($state->rotationTeamA[1])->toBe($homePlayers->last()->getKey()) - ->and($state->rotationTeamB[1])->toBe($awayPlayers[1]->getKey()); + ->and($state->rotationTeamA[1])->toBe(7) + ->and($state->rotationTeamB[1])->toBe(2); }); test('recalculation job rebuilds snapshots from scratch up to a cutoff time', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + ensureRostersForProjection($game, $homeTeam, $awayTeam); Carbon::setTestNow('2026-03-07 10:00:00'); $game->recordToss(TeamSide::Home, TeamAB::TeamA); + submitProjectionLineupsForSet($game, 1); Carbon::setTestNow('2026-03-07 10:01:00'); $game->recordSetStarted(); @@ -93,9 +117,239 @@ $latest = $game->fresh()->stateAt(); - expect($game->fresh()->stateSnapshots)->toHaveCount(3) + expect($game->fresh()->stateSnapshots)->toHaveCount(5) ->and($latest->setNumber)->toBe(1) ->and($latest->scoreTeamA)->toBe(1) ->and($latest->scoreTeamB)->toBe(0) ->and($latest->servingTeam)->toBe(TeamAB::TeamA); }); + +test('state resets points as soon as a set ends before the next set starts', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + ensureRostersForProjection($game, $homeTeam, $awayTeam); + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + submitProjectionLineupsForSet($game, 1); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $state = $game->fresh()->stateAt(); + + expect($game->fresh()->events->last()->type)->toBe(GameEventType::SetEnded) + ->and($state->setNumber)->toBe(1) + ->and($state->setInProgress)->toBeFalse() + ->and($state->setsWonTeamA)->toBe(1) + ->and($state->setsWonTeamB)->toBe(0) + ->and($state->servingTeam)->toBe(TeamAB::TeamB) + ->and($state->scoreTeamA)->toBe(0) + ->and($state->scoreTeamB)->toBe(0); +}); + +test('fifth set toss clears the automatic rotation and then drives left side and serving', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + ensureRostersForProjection($game, $homeTeam, $awayTeam); + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + submitProjectionLineupsForSet($game, $game->stateAt()->setNumber + 1); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner($setWinner); + } + } + + $stateBeforeFifthSetToss = $game->fresh()->stateAt(); + + expect($stateBeforeFifthSetToss->setNumber)->toBe(4) + ->and($stateBeforeFifthSetToss->setsWonTeamA)->toBe(2) + ->and($stateBeforeFifthSetToss->setsWonTeamB)->toBe(2) + ->and($stateBeforeFifthSetToss->servingTeam)->toBeNull() + ->and($stateBeforeFifthSetToss->fifthSetLeftTeam)->toBeNull(); + + $game->recordToss(TeamSide::Home, TeamAB::TeamB, TeamAB::TeamB); + submitProjectionLineupsForSet($game, 5); + $game->recordSetStarted(); + + $stateAfterFifthSetStart = $game->fresh()->stateAt(); + + expect($stateAfterFifthSetStart->setNumber)->toBe(5) + ->and($stateAfterFifthSetStart->setInProgress)->toBeTrue() + ->and($stateAfterFifthSetStart->servingTeam)->toBe(TeamAB::TeamB) + ->and($stateAfterFifthSetStart->fifthSetLeftTeam)->toBe(TeamAB::TeamB); +}); + +test('fifth set side swap updates the left team while keeping the serving team unchanged', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + ensureRostersForProjection($game, $homeTeam, $awayTeam); + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + submitProjectionLineupsForSet($game, $game->stateAt()->setNumber + 1); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner($setWinner); + } + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA, TeamAB::TeamA); + submitProjectionLineupsForSet($game, 5); + $game->recordSetStarted(); + + for ($index = 0; $index < 8; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordCourtSidesSwapped(); + + $state = $game->fresh()->stateAt(); + + expect($state->setNumber)->toBe(5) + ->and($state->fifthSetLeftTeam)->toBe(TeamAB::TeamB) + ->and($state->fifthSetSideSwapped)->toBeTrue() + ->and($state->servingTeam)->toBe(TeamAB::TeamA) + ->and($state->scoreTeamA)->toBe(8) + ->and($state->scoreTeamB)->toBe(0); +}); + +test('serving remains on the same court side after a set ends and sides swap', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + ensureRostersForProjection($game, $homeTeam, $awayTeam); + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + submitProjectionLineupsForSet($game, 1); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + submitProjectionLineupsForSet($game, 2); + $game->recordSetStarted(); + + $state = $game->fresh()->stateAt(); + + expect($state->setNumber)->toBe(2) + ->and($state->setInProgress)->toBeTrue() + ->and($state->setsWonTeamA)->toBe(1) + ->and($state->setsWonTeamB)->toBe(0) + ->and($state->servingTeam)->toBe(TeamAB::TeamB); +}); + +test('serving for the next set does not depend on the previous set winner', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + ensureRostersForProjection($game, $homeTeam, $awayTeam); + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + submitProjectionLineupsForSet($game, 1); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamB); + } + + $stateAfterSetEnd = $game->fresh()->stateAt(); + + expect($stateAfterSetEnd->setNumber)->toBe(1) + ->and($stateAfterSetEnd->setInProgress)->toBeFalse() + ->and($stateAfterSetEnd->setsWonTeamA)->toBe(0) + ->and($stateAfterSetEnd->setsWonTeamB)->toBe(1) + ->and($stateAfterSetEnd->servingTeam)->toBe(TeamAB::TeamB); + + submitProjectionLineupsForSet($game, 2); + $game->recordSetStarted(); + + $stateAtSetTwoStart = $game->fresh()->stateAt(); + + expect($stateAtSetTwoStart->setNumber)->toBe(2) + ->and($stateAtSetTwoStart->setInProgress)->toBeTrue() + ->and($stateAtSetTwoStart->servingTeam)->toBe(TeamAB::TeamB); +}); + +test('game state snapshot accepts a serialized serving team and casts it back to enum', function (): void { + $game = Game::factory()->create(); + + $event = GameEvent::withoutEvents(fn (): GameEvent => GameEvent::query()->create([ + 'game_id' => $game->getKey(), + 'type' => GameEventType::SetStarted, + 'payload' => new SetStartedPayload, + 'created_at' => now(), + ])); + + $snapshot = GameStateSnapshot::query()->create([ + 'game_id' => $game->getKey(), + 'game_event_id' => $event->getKey(), + 'set_number' => 1, + 'score_team_a' => 0, + 'score_team_b' => 0, + 'sets_won_team_a' => 0, + 'sets_won_team_b' => 0, + 'timeouts_team_a' => 0, + 'timeouts_team_b' => 0, + 'substitutions_team_a' => 0, + 'substitutions_team_b' => 0, + 'team_a_side' => TeamSide::Away->value, + 'serving_team' => TeamAB::TeamB->value, + 'rotation_team_a' => [], + 'rotation_team_b' => [], + 'set_in_progress' => true, + 'game_ended' => false, + 'created_at' => now(), + ]); + + expect($snapshot->team_a_side)->toBe(TeamSide::Away) + ->and($snapshot->serving_team)->toBe(TeamAB::TeamB) + ->and($snapshot->getRawOriginal('team_a_side'))->toBe(TeamSide::Away->value) + ->and($snapshot->getRawOriginal('serving_team'))->toBe(TeamAB::TeamB->value); +}); + +function ensureRostersForProjection(Game $game, Team $homeTeam, Team $awayTeam): void +{ + $homePlayers = Player::factory()->for($homeTeam)->count(7)->create(); + $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); + + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } +} + +function submitProjectionLineupsForSet(Game $game, int $set): void +{ + $game->recordLineup($set, TeamAB::TeamA, projectionLineup()); + $game->recordLineup($set, TeamAB::TeamB, projectionLineup()); +} + +/** + * @return array + */ +function projectionLineup(): array +{ + return [ + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + 6 => 6, + ]; +} diff --git a/tests/Feature/LineupSubmissionTest.php b/tests/Feature/LineupSubmissionTest.php new file mode 100644 index 0000000..3da0a9f --- /dev/null +++ b/tests/Feature/LineupSubmissionTest.php @@ -0,0 +1,329 @@ +set('game.between_sets_duration', 0); +}); + +/** + * @return array + */ +function validLineupInput(): array +{ + return [ + 1 => '1', + 2 => '2', + 3 => '3', + 4 => '4', + 5 => '5', + 6 => '6', + ]; +} + +/** + * @return array + */ +function validLineupPositions(): array +{ + return [ + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + 6 => 6, + ]; +} + +function prepareGameForLineupSubmission(): Game +{ + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $homeLibero = Player::factory()->for($homeTeam)->create(); + $game->addPlayer($homeLibero, number: 99, isLibero: true); + + $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + return $game; +} + +test('lineup submission is hidden before toss is submitted', function (): void { + $game = Game::factory()->create(); + + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + ->assertDontSee('Submit Lineup') + ->assertDontSee('Team A Lineup'); +}); + +test('lineup submission renders team a button and modal after toss is submitted', function (): void { + $game = prepareGameForLineupSubmission(); + + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + ->assertSee('Submit Lineup') + ->assertSee('Team A Lineup') + ->assertSeeHtml('submit-lineup-team_a-left') + ->assertSeeHtml('name="lineup[1]"') + ->assertSeeHtml('autofocus') + ->assertSeeHtml('name="lineup[6]"') + ->assertSeeHtml('data-lineup-roster-numbers') + ->assertSeeHtml('data-lineup-roster-number="1"') + ->assertSeeHtml('data-lineup-roster-number="6"') + ->assertDontSeeHtml('data-lineup-roster-number="99"') + ->assertDontSeeHtml('data-lineup-roster-number="11"') + ->assertSee('Submit'); +}); + +test('lineup submission renders team b button and modal after toss is submitted', function (): void { + $game = prepareGameForLineupSubmission(); + + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamB, 'gameId' => $game->getKey()]) + ->assertSee('Submit Lineup') + ->assertSee('Team B Lineup') + ->assertSeeHtml('submit-lineup-team_b-left') + ->assertSeeHtml('name="lineup[1]"') + ->assertSeeHtml('name="lineup[6]"') + ->assertSeeHtml('data-lineup-roster-numbers') + ->assertSeeHtml('data-lineup-roster-number="11"') + ->assertSeeHtml('data-lineup-roster-number="16"') + ->assertDontSeeHtml('data-lineup-roster-number="99"') + ->assertDontSeeHtml('data-lineup-roster-number="1"') + ->assertSee('Submit'); +}); + +test('lineup submission rejects unsupported team value', function (): void { + $game = Game::factory()->create(); + + expect(fn (): Testable => Livewire::test(LineupSubmission::class, ['team' => 'invalid', 'gameId' => $game->getKey()])) + ->toThrow(ViewException::class); +}); + +test('lineup submission modal name includes the court side to avoid stale modal reuse', function (): void { + $game = prepareGameForLineupSubmission(); + + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamB, 'gameId' => $game->getKey(), 'courtSide' => 'right']) + ->assertSeeHtml('submit-lineup-team_b-right') + ->assertDontSeeHtml('submit-lineup-team_b-left'); +}); + +test('lineup submission records an event and dispatches a refresh event', function (): void { + $game = prepareGameForLineupSubmission(); + + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + ->set('lineup', validLineupInput()) + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertSet('lineup.1', ''); + + $lineupEvent = $game->fresh()->events->last(); + + expect($lineupEvent)->not->toBeNull() + ->and($lineupEvent->type)->toBe(GameEventType::LineupSubmitted) + ->and($lineupEvent->payload->positions)->toBe([ + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + 6 => 6, + ]); +}); + +test('lineup submission requires positive integers', function (): void { + $game = prepareGameForLineupSubmission(); + $lineup = validLineupInput(); + $lineup[1] = '0'; + + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + ->set('lineup', $lineup) + ->call('submit') + ->assertHasErrors(['submit']) + ->assertHasNoErrors(['lineup.1', 'lineup.2', 'lineup.3', 'lineup.4', 'lineup.5', 'lineup.6']); +}); + +test('lineup submission requires all roster numbers to be different', function (): void { + $game = prepareGameForLineupSubmission(); + $lineup = validLineupInput(); + $lineup[2] = '1'; + + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + ->set('lineup', $lineup) + ->call('submit') + ->assertHasErrors(['submit']) + ->assertHasNoErrors(['lineup.1', 'lineup.2', 'lineup.3', 'lineup.4', 'lineup.5', 'lineup.6']); + + expect($game->fresh()->events)->toHaveCount(1); +}); + +test('lineup submission rejects roster numbers not eligible for the selected team', function (string $invalidRosterNumber): void { + $game = prepareGameForLineupSubmission(); + $lineup = validLineupInput(); + $lineup[1] = $invalidRosterNumber; + + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + ->set('lineup', $lineup) + ->call('submit') + ->assertHasErrors(['submit']) + ->assertHasNoErrors(['lineup.1', 'lineup.2', 'lineup.3', 'lineup.4', 'lineup.5', 'lineup.6']); + + expect($game->fresh()->events)->toHaveCount(1); +})->with([ + 'away team roster number' => ['11'], + 'libero roster number' => ['99'], +]); + +test('lineup submission is aware of the injected game context', function (): void { + $game = Game::factory()->create(); + + Livewire::test(LineupSubmission::class, [ + 'team' => TeamAB::TeamA, + 'gameId' => $game->getKey(), + 'gameState' => GameState::fromAttributes([ + 'set_number' => 2, + 'serving_team' => TeamAB::TeamB->value, + ]), + ]) + ->assertSet('gameId', $game->getKey()) + ->assertSet('gameState', fn (GameState $gameState): bool => $gameState->setNumber === 2 + && $gameState->servingTeam === TeamAB::TeamB); +}); + +test('lineup submission button is hidden after the lineup is already submitted for the same team and upcoming set', function (): void { + $game = prepareGameForLineupSubmission(); + $game->recordLineup(1, TeamAB::TeamA, validLineupPositions()); + + Livewire::test(LineupSubmission::class, [ + 'team' => TeamAB::TeamA, + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->assertDontSee('Submit Lineup') + ->assertDontSee('Team A Lineup'); +}); + +test('lineup submission button remains visible for the other team when only one team has submitted', function (): void { + $game = prepareGameForLineupSubmission(); + $game->recordLineup(1, TeamAB::TeamA, validLineupPositions()); + + Livewire::test(LineupSubmission::class, [ + 'team' => TeamAB::TeamB, + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->assertSee('Submit Lineup') + ->assertSee('Team B Lineup'); +}); + +test('lineup submission is hidden before the fifth set toss is submitted', function (): void { + $game = tiedLineupGameReadyForFifthSet(); + + Livewire::test(LineupSubmission::class, [ + 'team' => TeamAB::TeamA, + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->assertDontSee('Submit Lineup') + ->assertDontSee('Team A Lineup'); +}); + +test('lineup submission visibility follows snapshot state without querying lineup events', function (): void { + $game = Game::factory()->create(); + + $stateEvent = GameEvent::withoutEvents(fn (): GameEvent => GameEvent::query()->create([ + 'game_id' => $game->getKey(), + 'type' => GameEventType::SetStarted, + 'payload' => new SetStartedPayload, + 'created_at' => Carbon::now(), + ])); + + GameStateSnapshot::query()->create([ + 'game_id' => $game->getKey(), + 'game_event_id' => $stateEvent->getKey(), + 'set_number' => 0, + 'score_team_a' => 0, + 'score_team_b' => 0, + 'sets_won_team_a' => 0, + 'sets_won_team_b' => 0, + 'timeouts_team_a' => 0, + 'timeouts_team_b' => 0, + 'substitutions_team_a' => 0, + 'substitutions_team_b' => 0, + 'team_a_side' => TeamSide::Home->value, + 'serving_team' => TeamAB::TeamA->value, + 'rotation_team_a' => [1 => 1], + 'rotation_team_b' => [], + 'set_in_progress' => false, + 'game_ended' => false, + 'created_at' => Carbon::now(), + ]); + + Livewire::test(LineupSubmission::class, [ + 'team' => TeamAB::TeamA, + 'gameId' => $game->getKey(), + ]) + ->assertDontSee('Submit Lineup'); + + Livewire::test(LineupSubmission::class, [ + 'team' => TeamAB::TeamB, + 'gameId' => $game->getKey(), + ]) + ->assertSee('Submit Lineup') + ->assertSee('Team B Lineup'); +}); + +function tiedLineupGameReadyForFifthSet(): Game +{ + $game = prepareGameForLineupSubmission(); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + $set = $game->stateAt()->setNumber + 1; + $game->recordLineup($set, TeamAB::TeamA, validLineupPositions()); + $game->recordLineup($set, TeamAB::TeamB, [ + 1 => 11, + 2 => 12, + 3 => 13, + 4 => 14, + 5 => 15, + 6 => 16, + ]); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner($setWinner); + } + } + + return $game->fresh(); +} diff --git a/tests/Feature/RosterSubmissionTest.php b/tests/Feature/RosterSubmissionTest.php new file mode 100644 index 0000000..3b40114 --- /dev/null +++ b/tests/Feature/RosterSubmissionTest.php @@ -0,0 +1,491 @@ +set('game.between_sets_duration', 0); +}); + +test('initial roster submission is shown before the initial toss', function (): void { + $game = Game::factory()->create(); + + Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]) + ->assertSee('Submit rosters') + ->assertSeeHtml('data-submit-roster-button'); +}); + +test('submit rosters modal shows home and away players and staff', function (): void { + $game = Game::factory()->create(); + Player::factory()->for($game->homeTeam)->named('Anna', 'Zephyr')->create(); + Player::factory()->for($game->awayTeam)->named('Dora', 'Young')->create(); + Staff::factory()->for($game->homeTeam)->asCoach()->named('Helen', 'Coach')->create(); + Staff::factory()->for($game->awayTeam)->asDoctor()->named('Mila', 'Doctor')->create(); + + Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]) + ->call('openRosterModal') + ->assertSee('Anna Zephyr') + ->assertSee('Dora Young') + ->assertSee('Captain?') + ->assertSee('Helen Coach') + ->assertSee('Mila Doctor') + ->assertSee(StaffRole::Coach->value) + ->assertSee(StaffRole::Doctor->value); +}); + +test('submitting rosters opens confirmation and confirm saves selected players and checked staff for both teams', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(7)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(7)->create(); + $homeCoach = Staff::factory()->for($game->homeTeam)->asCoach()->create(); + $awayDoctor = Staff::factory()->for($game->awayTeam)->asDoctor()->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", $index === 6 ? '12' : (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", $index === 6 ? '22' : (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->set("homeLiberoSelection.{$homePlayers[6]->getKey()}", true) + ->set("awayLiberoSelection.{$awayPlayers[6]->getKey()}", true) + ->set("homeStaffSelection.{$homeCoach->getKey()}", true) + ->set("awayStaffSelection.{$awayDoctor->getKey()}", true) + ->call('submitRosters') + ->assertHasNoErrors(); + + expect($game->fresh()->homePlayers)->toHaveCount(0) + ->and($game->fresh()->awayPlayers)->toHaveCount(0) + ->and($game->fresh()->rosters_submitted)->toBeFalse(); + + $component + ->assertSee('Confirm rosters') + ->assertSee($homePlayers[0]->last_name.' '.mb_substr((string) $homePlayers[0]->first_name, 0, 1).'.') + ->assertSee((string) 12) + ->assertSee('Bench') + ->call('confirmRosters') + ->assertHasNoErrors() + ->assertDontSee('Submit rosters'); + + $freshGame = $game->fresh(); + + expect($freshGame->homePlayers)->toHaveCount(7) + ->and($freshGame->awayPlayers)->toHaveCount(7) + ->and($freshGame->homePlayers->firstWhere('id', $homePlayers[0]->getKey())?->roster->is_captain)->toBeTrue() + ->and($freshGame->homePlayers->firstWhere('id', $homePlayers[6]->getKey())?->roster->number)->toBe(12) + ->and($freshGame->homePlayers->firstWhere('id', $homePlayers[6]->getKey())?->roster->is_libero)->toBeTrue() + ->and($freshGame->awayPlayers->firstWhere('id', $awayPlayers[0]->getKey())?->roster->is_captain)->toBeTrue() + ->and($freshGame->awayPlayers->firstWhere('id', $awayPlayers[6]->getKey())?->roster->number)->toBe(22) + ->and($freshGame->awayPlayers->firstWhere('id', $awayPlayers[6]->getKey())?->roster->is_libero)->toBeTrue() + ->and($freshGame->homeStaff->first()?->getKey())->toBe($homeCoach->getKey()) + ->and($freshGame->awayStaff->first()?->getKey())->toBe($awayDoctor->getKey()) + ->and($freshGame->rosters_submitted)->toBeTrue(); +}); + +test('confirming rosters only saves the checked staff members for each team', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + $homeCoach = Staff::factory()->for($game->homeTeam)->asCoach()->create(); + $homeDoctor = Staff::factory()->for($game->homeTeam)->asDoctor()->create(); + $homeTherapist = Staff::factory()->for($game->homeTeam)->asTherapist()->create(); + $awayCoach = Staff::factory()->for($game->awayTeam)->asCoach()->create(); + $awayAssistantCoach = Staff::factory()->for($game->awayTeam)->asAssistantCoach()->create(); + $awayDoctor = Staff::factory()->for($game->awayTeam)->asDoctor()->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->set("homeStaffSelection.{$homeCoach->getKey()}", true) + ->set("homeStaffSelection.{$homeDoctor->getKey()}", true) + ->set("awayStaffSelection.{$awayAssistantCoach->getKey()}", true) + ->call('confirmRosters') + ->assertHasNoErrors(); + + $freshGame = $game->fresh(); + + expect($freshGame->homeStaff->modelKeys()) + ->toBe([$homeCoach->getKey(), $homeDoctor->getKey()]) + ->and($freshGame->homeStaff->contains(fn (Staff $staff): bool => $staff->getKey() === $homeTherapist->getKey()))->toBeFalse() + ->and($freshGame->awayStaff->modelKeys())->toBe([$awayAssistantCoach->getKey()]) + ->and($freshGame->awayStaff->contains(fn (Staff $staff): bool => $staff->getKey() === $awayCoach->getKey()))->toBeFalse() + ->and($freshGame->awayStaff->contains(fn (Staff $staff): bool => $staff->getKey() === $awayDoctor->getKey()))->toBeFalse(); +}); + +test('returning to roster from confirmation preserves entered selections', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[1]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[2]->getKey()) + ->set("homeLiberoSelection.{$homePlayers[5]->getKey()}", true) + ->set("awayLiberoSelection.{$awayPlayers[4]->getKey()}", true) + ->call('submitRosters') + ->assertSee('Confirm rosters') + ->call('returnToRoster') + ->assertSet("homeRosterInputs.{$homePlayers[0]->getKey()}", '1') + ->assertSet("awayRosterInputs.{$awayPlayers[0]->getKey()}", '11') + ->assertSet('homeCaptainSelection', (string) $homePlayers[1]->getKey()) + ->assertSet('awayCaptainSelection', (string) $awayPlayers[2]->getKey()) + ->assertSet("homeLiberoSelection.{$homePlayers[5]->getKey()}", true) + ->assertSet("awayLiberoSelection.{$awayPlayers[4]->getKey()}", true); +}); + +test('confirmation groups and orders roster details for each team', function (): void { + $game = Game::factory()->create(); + + $homePlayers = collect([ + Player::factory()->for($game->homeTeam)->named('Alice', 'Zulu')->create(), + Player::factory()->for($game->homeTeam)->named('Beth', 'Able')->create(), + Player::factory()->for($game->homeTeam)->named('Cara', 'Mason')->create(), + Player::factory()->for($game->homeTeam)->named('Dana', 'Nolan')->create(), + Player::factory()->for($game->homeTeam)->named('Eve', 'Olsen')->create(), + Player::factory()->for($game->homeTeam)->named('Faye', 'Piper')->create(), + Player::factory()->for($game->homeTeam)->named('Gia', 'Quill')->create(), + ]); + + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $homeCoach = Staff::factory()->for($game->homeTeam)->asCoach()->named('Holly', 'Coach')->create(); + $assistantBravo = Staff::factory()->for($game->homeTeam)->asAssistantCoach()->named('Iris', 'Bravo')->create(); + $assistantAlpha = Staff::factory()->for($game->homeTeam)->asAssistantCoach()->named('Jade', 'Alpha')->create(); + $therapist = Staff::factory()->for($game->homeTeam)->asTherapist()->named('Kora', 'Therapist')->create(); + $doctor = Staff::factory()->for($game->homeTeam)->asDoctor()->named('Lina', 'Doctor')->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $number = match ($index) { + 0 => '8', + 1 => '3', + 2 => '11', + 3 => '5', + 4 => '7', + 5 => '2', + default => '13', + }; + + $component->set("homeRosterInputs.{$player->getKey()}", $number); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 21)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[1]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->set("homeLiberoSelection.{$homePlayers[6]->getKey()}", true) + ->set("homeStaffSelection.{$homeCoach->getKey()}", true) + ->set("homeStaffSelection.{$assistantBravo->getKey()}", true) + ->set("homeStaffSelection.{$assistantAlpha->getKey()}", true) + ->set("homeStaffSelection.{$therapist->getKey()}", true) + ->set("homeStaffSelection.{$doctor->getKey()}", true) + ->call('submitRosters') + ->assertSeeInOrder([ + 'Players', + '3', + 'Able B.', + '5', + 'Nolan D.', + '7', + 'Olsen E.', + '8', + 'Zulu A.', + '11', + 'Mason C.', + 'Liberos', + '13', + 'Quill G.', + 'Bench', + 'C', + 'Coach H.', + 'AC1', + 'Alpha J.', + 'AC2', + 'Bravo I.', + 'T', + 'Therapist K.', + 'D', + 'Doctor L.', + ]); +}); + +test('submitting rosters requires six non-libero players per team', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", $index === 5 ? '7' : (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->set("homeLiberoSelection.{$homePlayers[5]->getKey()}", true) + ->call('confirmRosters') + ->assertHasErrors(['homeRosterInputs']); +}); + +test('submitting rosters requires player numbers to be between 1 and 99', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", $index === 0 ? '0' : (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", $index === 0 ? '100' : (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->call('confirmRosters') + ->assertHasErrors([ + "homeRosterInputs.{$homePlayers[0]->getKey()}", + "awayRosterInputs.{$awayPlayers[0]->getKey()}", + ]); +}); + +test('submitting rosters requires unique player numbers within each team including liberos', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(7)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", $index === 6 ? '1' : (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->set("homeLiberoSelection.{$homePlayers[6]->getKey()}", true) + ->call('confirmRosters') + ->assertHasErrors(["homeRosterInputs.{$homePlayers[6]->getKey()}"]); +}); + +test('submitting rosters allows at most two liberos per team', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(8)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->set("homeLiberoSelection.{$homePlayers[5]->getKey()}", true) + ->set("homeLiberoSelection.{$homePlayers[6]->getKey()}", true) + ->set("homeLiberoSelection.{$homePlayers[7]->getKey()}", true) + ->call('confirmRosters') + ->assertHasErrors(['homeRosterInputs']); +}); + +test('submitting rosters allows at most twelve non-libero players per team', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(14)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 21)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->set("homeLiberoSelection.{$homePlayers[12]->getKey()}", true) + ->set("homeLiberoSelection.{$homePlayers[13]->getKey()}", true) + ->call('confirmRosters') + ->assertHasNoErrors(['homeRosterInputs']); + + $component + ->set("homeLiberoSelection.{$homePlayers[12]->getKey()}", false) + ->call('confirmRosters') + ->assertHasErrors(['homeRosterInputs']); +}); + +test('submitting rosters requires a libero to be a rostered player', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", $index === 5 ? '' : (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->set("homeLiberoSelection.{$homePlayers[5]->getKey()}", true) + ->call('confirmRosters') + ->assertHasErrors(["homeRosterInputs.{$homePlayers[5]->getKey()}"]) + ->assertHasNoErrors(['homeCaptainSelection']); +}); + +test('submitting rosters requires one captain per team', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->call('confirmRosters') + ->assertHasErrors(['homeCaptainSelection']); +}); + +test('submitting rosters requires the captain to be one of the rostered players', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[5]->getKey()) + ->set("awayRosterInputs.{$awayPlayers[5]->getKey()}", '') + ->call('confirmRosters') + ->assertHasErrors(['awayCaptainSelection']) + ->assertHasNoErrors(["awayRosterInputs.{$awayPlayers[5]->getKey()}"]); +}); + +test('roster validation errors clear when the user fixes the input', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", $index === 1 ? '1' : (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->call('confirmRosters') + ->assertHasErrors(["homeRosterInputs.{$homePlayers[1]->getKey()}"]); + + $component + ->set("homeRosterInputs.{$homePlayers[1]->getKey()}", '2') + ->assertHasNoErrors(["homeRosterInputs.{$homePlayers[1]->getKey()}"]); +}); + +function seedRosterableTeams(Game $game): void +{ + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->call('confirmRosters') + ->assertHasNoErrors(); +} diff --git a/tests/Feature/ScoreboardTest.php b/tests/Feature/ScoreboardTest.php new file mode 100644 index 0000000..8dc4c68 --- /dev/null +++ b/tests/Feature/ScoreboardTest.php @@ -0,0 +1,194 @@ +recordToss(TeamSide::Away, TeamAB::TeamA); + + $teamACode = $game->awayTeam->country_code; + $teamBCode = $game->homeTeam->country_code; + + Livewire::test(Scoreboard::class, [ + 'gameId' => $game->getKey(), + 'gameState' => GameState::fromAttributes([ + 'sets_won_team_a' => 0, + 'sets_won_team_b' => 0, + 'score_team_a' => 14, + 'score_team_b' => 19, + ]), + ]) + ->assertSeeHtml('data-scoreboard-left-team="team_a"') + ->assertSeeHtml('data-scoreboard-right-team="team_b"') + ->assertSeeInOrder([ + $teamACode, + 'Sets', + $teamBCode, + '0', + ':', + '0', + $teamACode, + 'Points', + $teamBCode, + '14', + ':', + '19', + ]); +}); + +test('scoreboard swaps left and right teams when completed set count is odd', function (): void { + $game = gameWithDistinctTeamCountryCodes(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + $teamACode = $game->homeTeam->country_code; + $teamBCode = $game->awayTeam->country_code; + + Livewire::test(Scoreboard::class, [ + 'gameId' => $game->getKey(), + 'gameState' => GameState::fromAttributes([ + 'sets_won_team_a' => 2, + 'sets_won_team_b' => 1, + 'score_team_a' => 17, + 'score_team_b' => 21, + ]), + ]) + ->assertSeeHtml('data-scoreboard-left-team="team_b"') + ->assertSeeHtml('data-scoreboard-right-team="team_a"') + ->assertSeeInOrder([ + $teamBCode, + 'Sets', + $teamACode, + '1', + ':', + '2', + $teamBCode, + 'Points', + $teamACode, + '21', + ':', + '17', + ]); +}); + +test('scoreboard resolves team a side from latest snapshot without requiring toss event lookup', function (): void { + $game = gameWithDistinctTeamCountryCodes(); + + $stateEvent = GameEvent::withoutEvents(fn (): GameEvent => GameEvent::query()->create([ + 'game_id' => $game->getKey(), + 'type' => GameEventType::SetStarted, + 'payload' => new SetStartedPayload, + 'created_at' => Carbon::now(), + ])); + + GameStateSnapshot::query()->create([ + 'game_id' => $game->getKey(), + 'game_event_id' => $stateEvent->getKey(), + 'set_number' => 1, + 'score_team_a' => 0, + 'score_team_b' => 0, + 'sets_won_team_a' => 0, + 'sets_won_team_b' => 0, + 'timeouts_team_a' => 0, + 'timeouts_team_b' => 0, + 'substitutions_team_a' => 0, + 'substitutions_team_b' => 0, + 'team_a_side' => TeamSide::Away->value, + 'serving_team' => TeamAB::TeamA->value, + 'rotation_team_a' => [], + 'rotation_team_b' => [], + 'set_in_progress' => false, + 'game_ended' => false, + 'created_at' => Carbon::now(), + ]); + + $teamACode = $game->awayTeam->country_code; + $teamBCode = $game->homeTeam->country_code; + + Livewire::test(Scoreboard::class, [ + 'gameId' => $game->getKey(), + 'gameState' => GameState::fromAttributes([ + 'sets_won_team_a' => 0, + 'sets_won_team_b' => 0, + 'score_team_a' => 8, + 'score_team_b' => 4, + ]), + ]) + ->assertSeeHtml('data-scoreboard-left-team="team_a"') + ->assertSeeHtml('data-scoreboard-right-team="team_b"') + ->assertSeeInOrder([ + $teamACode, + 'Sets', + $teamBCode, + '0', + ':', + '0', + $teamACode, + 'Points', + $teamBCode, + '8', + ':', + '4', + ]); +}); + +test('scoreboard swaps teams after the fifth set court change', function (): void { + $game = gameWithDistinctTeamCountryCodes(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + $teamACode = $game->homeTeam->country_code; + $teamBCode = $game->awayTeam->country_code; + + Livewire::test(Scoreboard::class, [ + 'gameId' => $game->getKey(), + 'gameState' => GameState::fromAttributes([ + 'set_number' => 5, + 'sets_won_team_a' => 2, + 'sets_won_team_b' => 2, + 'score_team_a' => 8, + 'score_team_b' => 7, + 'fifth_set_left_team' => TeamAB::TeamB->value, + 'fifth_set_side_swapped' => true, + ]), + ]) + ->assertSeeHtml('data-scoreboard-left-team="team_b"') + ->assertSeeHtml('data-scoreboard-right-team="team_a"') + ->assertSeeInOrder([ + $teamBCode, + 'Sets', + $teamACode, + '2', + ':', + '2', + $teamBCode, + 'Points', + $teamACode, + '7', + ':', + '8', + ]); +}); + +function gameWithDistinctTeamCountryCodes(): Game +{ + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + + while ($awayTeam->country_code === $homeTeam->country_code) { + $awayTeam = Team::factory()->create(); + } + + return Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); +} diff --git a/tests/Feature/ScoresheetDataRepositoryTest.php b/tests/Feature/ScoresheetDataRepositoryTest.php new file mode 100644 index 0000000..c727738 --- /dev/null +++ b/tests/Feature/ScoresheetDataRepositoryTest.php @@ -0,0 +1,103 @@ +playersForSide($game, TeamSide::Home); + $awayPlayers = $repository->playersForSide($game, TeamSide::Away); + + expect(collect($homePlayers)->pluck('number')->all())->toBe([3, 12]) + ->and(collect($homePlayers)->pluck('last_name')->all())->toBe(['Anderson', 'Zephyr']) + ->and(collect($awayPlayers)->pluck('number')->all())->toBe([2, 9]) + ->and(collect($awayPlayers)->pluck('last_name')->all())->toBe(['Baker', 'Young']); +}); + +test('players for side reflects roster changes on repeated reads', function (): void { + $game = gameWithNumberedRostersForScoresheetDataRepository(); + $repository = app(ScoresheetDataRepository::class); + + $playersBeforeRosterChange = $repository->playersForSide($game, TeamSide::Home); + + $newHomePlayer = Player::factory()->for($game->homeTeam)->named('Nina', 'Newest')->create(); + $game->addPlayer($newHomePlayer, number: 8); + + $playersAfterRosterChange = $repository->playersForSide($game, TeamSide::Home); + + expect(collect($playersBeforeRosterChange)->pluck('player_key')->all())->not->toContain($newHomePlayer->getKey()) + ->and(collect($playersAfterRosterChange)->pluck('player_key')->all())->toContain($newHomePlayer->getKey()); +}); + +test('latest toss payload returns the recorded toss payload', function (): void { + $game = gameWithNumberedRostersForScoresheetDataRepository(); + $game->recordToss(TeamSide::Away, TeamAB::TeamB); + + $repository = app(ScoresheetDataRepository::class); + $payload = $repository->latestTossPayload($game); + + expect($payload)->not->toBeNull() + ->and($payload?->teamA)->toBe(TeamSide::Away) + ->and($payload?->serving)->toBe(TeamAB::TeamB); +}); + +test('latest toss payload reflects new toss events on repeated reads', function (): void { + $game = gameWithNumberedRostersForScoresheetDataRepository(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + $repository = app(ScoresheetDataRepository::class); + + $payloadBeforeSecondToss = $repository->latestTossPayload($game); + $game->events()->create([ + 'type' => GameEventType::TossCompleted, + 'payload' => new TossCompletedPayload( + teamA: TeamSide::Away, + serving: TeamAB::TeamB, + ), + ]); + + $payloadAfterRecordingToss = $repository->latestTossPayload($game); + + expect($payloadBeforeSecondToss)->not->toBeNull() + ->and($payloadBeforeSecondToss?->teamA)->toBe(TeamSide::Home) + ->and($payloadBeforeSecondToss?->serving)->toBe(TeamAB::TeamA) + ->and($payloadAfterRecordingToss?->teamA)->toBe(TeamSide::Away) + ->and($payloadAfterRecordingToss?->serving)->toBe(TeamAB::TeamB); +}); + +function gameWithNumberedRostersForScoresheetDataRepository(): Game +{ + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayerOne = Player::factory()->for($homeTeam)->named('Anna', 'Zephyr')->create(); + $homePlayerTwo = Player::factory()->for($homeTeam)->named('Beth', 'Anderson')->create(); + $homeLibero = Player::factory()->for($homeTeam)->named('Cara', 'Libero')->create(); + + $awayPlayerOne = Player::factory()->for($awayTeam)->named('Dora', 'Young')->create(); + $awayPlayerTwo = Player::factory()->for($awayTeam)->named('Etta', 'Baker')->create(); + $awayLibero = Player::factory()->for($awayTeam)->named('Faye', 'Keeper')->create(); + + $game->addPlayer($homePlayerOne, number: 12); + $game->addPlayer($homePlayerTwo, number: 3); + $game->addPlayer($homeLibero, number: 1, isLibero: true); + $game->addPlayer($awayPlayerOne, number: 9); + $game->addPlayer($awayPlayerTwo, number: 2); + $game->addPlayer($awayLibero, number: 20, isLibero: true); + + return $game; +} diff --git a/tests/Feature/StartSetSubmissionTest.php b/tests/Feature/StartSetSubmissionTest.php new file mode 100644 index 0000000..2c6c859 --- /dev/null +++ b/tests/Feature/StartSetSubmissionTest.php @@ -0,0 +1,279 @@ +set('game.between_sets_duration', 0); +}); + +test('start set button is visible only when both team lineups are submitted for the upcoming set', function (): void { + $game = gameReadyToStartSet(); + + $game->recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->assertDontSee('Start Game'); + + $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->assertSee('Start Game'); +}); + +test('start set shows a break countdown after a set ends even when the next lineups are already submitted', function (): void { + config()->set('game.between_sets_duration', 180); + + $game = gameReadyToStartSet(); + $game->recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + $this->freezeSecond(function () use ($game): void { + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordLineup(2, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(2, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->assertDontSee('Start Set 2') + ->assertSee('Next set in') + ->assertSee('03:00') + ->assertSeeHtml('data-set-break-countdown'); + }); +}); + +test('start set button records set started event and dispatches refresh event', function (): void { + config()->set('game.between_sets_duration', 0); + + $game = gameReadyToStartSet(); + $game->recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->assertSee('Start Game') + ->call('startSet') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDontSee('Start Game'); + + $freshGame = $game->fresh(); + $latestEvent = $freshGame->events->last(); + + expect($latestEvent)->not->toBeNull() + ->and($latestEvent->type)->toBe(GameEventType::SetStarted) + ->and($freshGame->stateAt()->setNumber)->toBe(1) + ->and($freshGame->stateAt()->setInProgress)->toBeTrue(); +}); + +test('start set cannot be recorded before the break countdown elapses', function (): void { + config()->set('game.between_sets_duration', 180); + + $game = gameReadyToStartSet(); + $game->recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + $this->freezeSecond(function () use ($game): void { + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordLineup(2, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(2, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->call('startSet') + ->assertHasErrors(['startSet']); + + expect($game->fresh()->stateAt()->setInProgress)->toBeFalse() + ->and($game->fresh()->stateAt()->setNumber)->toBe(1); + }); +}); + +test('start set button is only shown for the opening set', function (): void { + config()->set('game.between_sets_duration', 0); + + $game = gameReadyToStartSet(); + $game->recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); + $game->recordSetStarted(); + + for ($i = 0; $i < 25; $i++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordLineup(2, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(2, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->assertDontSee('Start Game') + ->assertSeeHtml('x-init="$wire.startSet()"'); +}); + +test('start set button becomes visible after the break countdown elapses', function (): void { + config()->set('game.between_sets_duration', 180); + + $game = gameReadyToStartSet(); + $game->recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + $this->freezeSecond(function () use ($game): void { + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordLineup(2, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(2, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + $this->travel(3)->minutes(); + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->assertDontSee('Start Game') + ->assertDontSee('Next set in') + ->assertSeeHtml('x-init="$wire.startSet()"'); + }); +}); + +test('start set button visibility follows snapshot state without querying lineup events', function (): void { + config()->set('game.between_sets_duration', 0); + + $game = Game::factory()->create(); + + $stateEvent = GameEvent::withoutEvents(fn (): GameEvent => GameEvent::query()->create([ + 'game_id' => $game->getKey(), + 'type' => GameEventType::SetStarted, + 'payload' => new SetStartedPayload, + 'created_at' => Carbon::now(), + ])); + + GameStateSnapshot::query()->create([ + 'game_id' => $game->getKey(), + 'game_event_id' => $stateEvent->getKey(), + 'set_number' => 0, + 'score_team_a' => 0, + 'score_team_b' => 0, + 'sets_won_team_a' => 0, + 'sets_won_team_b' => 0, + 'timeouts_team_a' => 0, + 'timeouts_team_b' => 0, + 'substitutions_team_a' => 0, + 'substitutions_team_b' => 0, + 'team_a_side' => TeamSide::Home->value, + 'serving_team' => TeamAB::TeamA->value, + 'rotation_team_a' => [1 => 1], + 'rotation_team_b' => [1 => 11], + 'set_in_progress' => false, + 'game_ended' => false, + 'created_at' => Carbon::now(), + ]); + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey()]) + ->assertSee('Start Game'); +}); + +test('start set button stays hidden before the fifth set toss is submitted', function (): void { + config()->set('game.between_sets_duration', 0); + + $game = gameReadyToStartSet(); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + $set = $game->stateAt()->setNumber + 1; + $game->recordLineup($set, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup($set, TeamAB::TeamB, lineupPositionsForNumbers(11)); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner($setWinner); + } + } + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->assertDontSee('Start Set 5'); +}); + +test('start set shows the break countdown before the fifth set toss is submitted', function (): void { + config()->set('game.between_sets_duration', 0); + + $game = gameReadyToStartSet(); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + $set = $game->stateAt()->setNumber + 1; + $game->recordLineup($set, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup($set, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + $this->freezeSecond(function () use ($game, $setWinner): void { + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner($setWinner); + } + }); + } + + config()->set('game.between_sets_duration', 180); + + $this->freezeSecond(function () use ($game): void { + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->assertDontSee('Start Game') + ->assertSee('Next set in') + ->assertSeeHtml('data-set-break-countdown'); + }); +}); + +/** + * @return array + */ +function lineupPositionsForNumbers(int $start): array +{ + return [ + 1 => $start, + 2 => $start + 1, + 3 => $start + 2, + 4 => $start + 3, + 5 => $start + 4, + 6 => $start + 5, + ]; +} + +function gameReadyToStartSet(): Game +{ + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + return $game; +} diff --git a/tests/Feature/TeamRosterTest.php b/tests/Feature/TeamRosterTest.php new file mode 100644 index 0000000..46188fb --- /dev/null +++ b/tests/Feature/TeamRosterTest.php @@ -0,0 +1,839 @@ +set('game.between_sets_duration', 0); +}); + +test('team roster shows fallback text when there are no players', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ]) + ->assertDontSee('Team A') + ->assertDontSee('Team B') + ->assertSee('No players available.'); +}); + +test('team roster renders team a on the left with number markers only', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => submittedLineupState(), + ])->assertSeeInOrder([ + '3', + '12', + ]); +}); + +test('team roster renders team b on the right with number markers only', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamB, + 'leftSide' => false, + 'gameState' => submittedLineupState(), + ])->assertSeeInOrder([ + '2', + '9', + ]); +}); + +test('team roster resolves team a players from toss assignment', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + $game->recordToss(TeamSide::Away, TeamAB::TeamA); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => submittedLineupState(), + ])->assertSeeInOrder([ + '2', + '9', + ]); +}); + +test('team roster resolves toss assignment immediately after toss submission', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + $game->recordToss(TeamSide::Away, TeamAB::TeamA); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => submittedLineupState(), + ]) + ->assertSeeInOrder([ + '2', + '9', + ])->assertDontSeeHtml('data-team-roster-number="3"'); +}); + +test('team roster hides on-court players when lineup rotation exists', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => GameState::fromAttributes([ + 'rotation_team_a' => [ + 1 => 3, + ], + ]), + ]) + ->assertDontSeeHtml('data-team-roster-number="3"') + ->assertSeeHtml('data-team-roster-number="12"'); +}); + +test('team roster can render after a second Livewire request', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => submittedLineupState(), + ]) + ->assertSeeHtml('data-team-roster-number="3"') + ->call('$refresh') + ->assertSeeHtml('data-team-roster-number="3"'); +}); + +test('team roster shows placeholders when lineup is not submitted', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + $homePlayers = Player::factory()->for($homeTeam)->count(9)->create(); + + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1, isLibero: $index === 8); + } + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ]) + ->assertSeeHtml('data-team-roster-placeholder="1"') + ->assertSeeHtml('data-team-roster-placeholder="2"') + ->assertDontSeeHtml('data-team-roster-placeholder="3"') + ->assertDontSeeHtml('data-team-roster-number="1"') + ->assertDontSeeHtml('data-team-roster-number="8"'); +}); + +test('team roster renders left-side staff role circles in reverse row order for team a', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ]) + ->assertSeeHtml('data-team-roster-staff-list') + ->assertSeeHtml('flex-row-reverse') + ->assertSeeInOrder([ + 'data-team-roster-staff-role="C"', + 'data-team-roster-staff-role="A1"', + 'data-team-roster-staff-role="A2"', + 'data-team-roster-staff-role="D"', + 'data-team-roster-staff-role="T"', + ]); +}); + +test('team roster renders right-side staff role circles left to right for team b', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamB, + 'leftSide' => false, + ]) + ->assertSeeHtml('data-team-roster-staff-list') + ->assertDontSeeHtml('flex-row-reverse') + ->assertSeeInOrder([ + 'data-team-roster-staff-role="C"', + 'data-team-roster-staff-role="A1"', + 'data-team-roster-staff-role="A2"', + 'data-team-roster-staff-role="D"', + 'data-team-roster-staff-role="T"', + ]); +}); + +test('team roster applies left-side staff orientation even when the displayed team is team b', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamB, + 'leftSide' => true, + ]) + ->assertSeeHtml('data-team-roster-staff-list') + ->assertSeeHtml('flex-row-reverse') + ->assertSeeInOrder([ + 'data-team-roster-staff-role="C"', + 'data-team-roster-staff-role="A1"', + 'data-team-roster-staff-role="A2"', + 'data-team-roster-staff-role="D"', + 'data-team-roster-staff-role="T"', + ]); +}); + +test('team roster applies right-side staff orientation even when the displayed team is team a', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => false, + ]) + ->assertSeeHtml('data-team-roster-staff-list') + ->assertDontSeeHtml('flex-row-reverse') + ->assertSeeInOrder([ + 'data-team-roster-staff-role="C"', + 'data-team-roster-staff-role="A1"', + 'data-team-roster-staff-role="A2"', + 'data-team-roster-staff-role="D"', + 'data-team-roster-staff-role="T"', + ]); +}); + +test('team roster omits staff role circles when those roles are not on the roster', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homeCoach = Staff::factory()->for($homeTeam)->create(); + $homeDoctor = Staff::factory()->for($homeTeam)->create(); + + $game->addStaff($homeCoach, StaffRole::Coach); + $game->addStaff($homeDoctor, StaffRole::Doctor); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ]) + ->assertSeeHtml('data-team-roster-staff-role="C"') + ->assertSeeHtml('data-team-roster-staff-role="D"') + ->assertDontSeeHtml('data-team-roster-staff-role="A1"') + ->assertDontSeeHtml('data-team-roster-staff-role="A2"') + ->assertDontSeeHtml('data-team-roster-staff-role="T"'); +}); + +test('team roster bench only shows staff roles that were selected during roster submission', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + $homeCoach = Staff::factory()->for($game->homeTeam)->asCoach()->create(); + $homeAssistantCoach = Staff::factory()->for($game->homeTeam)->asAssistantCoach()->create(); + $homeDoctor = Staff::factory()->for($game->homeTeam)->asDoctor()->create(); + $homeTherapist = Staff::factory()->for($game->homeTeam)->asTherapist()->create(); + + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } + + $game->addStaff($homeCoach, StaffRole::Coach); + $game->addStaff($homeAssistantCoach, StaffRole::AssistantCoach); + $game->addStaff($homeDoctor, StaffRole::Doctor); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ]) + ->assertSeeHtml('data-team-roster-staff-role="C"') + ->assertSeeHtml('data-team-roster-staff-role="A1"') + ->assertSeeHtml('data-team-roster-staff-role="D"') + ->assertDontSeeHtml('data-team-roster-staff-role="A2"') + ->assertDontSeeHtml('data-team-roster-staff-role="T"'); + + expect($game->homeStaff->contains(fn (Staff $staff): bool => $staff->getKey() === $homeTherapist->getKey())) + ->toBeFalse(); +}); + +test('timeout card shows modal trigger when a set is in progress', function (): void { + $game = gameWithActiveSetForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ])->assertSeeHtml('data-team-roster-timeouts') + ->assertSeeHtml('request-timeout-team_a'); +}); + +test('timeout card is not a modal trigger when no set is in progress', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ])->assertSeeHtml('data-team-roster-timeouts') + ->assertDontSeeHtml('request-timeout-team_a'); +}); + +test('timeout card remains interactive when team has used all timeouts', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordTimeOut(TeamAB::TeamA); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ])->assertSeeHtml('data-team-roster-timeouts') + ->assertSeeHtml('request-timeout-team_a'); +}); + +test('requesting a timeout dispatches hasTimeoutLeft true when timeouts remain', function (): void { + $game = gameWithActiveSetForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->call('requestTimeout') + ->assertDispatched('timeout-recorded', team: 'team_a', hasTimeoutLeft: true, improperRequest: false, delayWarning: false, delayPenalty: false); +}); + +test('requesting a timeout records an improper request when no timeouts remain', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordTimeOut(TeamAB::TeamA); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->call('requestTimeout') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDispatched('timeout-recorded', team: 'team_a', hasTimeoutLeft: false, improperRequest: true, delayWarning: false, delayPenalty: false); + + $freshGame = $game->fresh(); + $latestEvent = $freshGame->events->last(); + + expect($latestEvent->type)->toBe(GameEventType::ImproperRequestRecorded) + ->and($freshGame->stateAt()->timeoutsTeamA)->toBe(2) + ->and($freshGame->stateAt()->improperRequestsTeamA)->toBe(1); +}); + +test('second improper timeout request records a delay warning across sets', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + + for ($i = 0; $i < 25; $i++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordLineup(2, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(2, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordTimeOut(TeamAB::TeamA); + + expect($game->fresh()->stateAt()->improperRequestsTeamA)->toBe(1); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->call('requestTimeout') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDispatched('timeout-recorded', team: 'team_a', hasTimeoutLeft: false, improperRequest: true, delayWarning: true, delayPenalty: false); + + $freshGame = $game->fresh(); + $latestEvent = $freshGame->events->last(); + + expect($latestEvent->type)->toBe(GameEventType::DelayWarningRecorded) + ->and($freshGame->stateAt()->improperRequestsTeamA)->toBe(1) + ->and($freshGame->stateAt()->delayWarningsTeamA)->toBe(1); +}); + +test('third improper timeout request records a delay penalty', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Substitution); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->call('requestTimeout') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDispatched('timeout-recorded', team: 'team_a', hasTimeoutLeft: false, improperRequest: true, delayWarning: false, delayPenalty: true); + + $freshGame = $game->fresh(); + $latestEvent = $freshGame->events->last(); + + expect($latestEvent->type)->toBe(GameEventType::DelayPenaltyRecorded) + ->and($freshGame->stateAt()->scoreTeamB)->toBe(1) + ->and($freshGame->stateAt()->servingTeam)->toBe(TeamAB::TeamB) + ->and($freshGame->stateAt()->delayPenaltiesTeamA)->toBe(1); +}); + +test('timeout card becomes interactive again for a new set after two timeouts in the previous set', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordTimeOut(TeamAB::TeamA); + + // End set 1 (auto-ended by reactor at 25-0) and start set 2 + for ($i = 0; $i < 25; $i++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + $game->recordLineup(2, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(2, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ])->assertSeeHtml('request-timeout-team_a'); +}); + +test('requesting a timeout records the event and dispatches events', function (): void { + $game = gameWithActiveSetForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->call('requestTimeout') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDispatched('timeout-recorded', team: 'team_a'); + + $latestEvent = $game->fresh()->events->last(); + expect($latestEvent->type)->toBe(GameEventType::TimeOutRequested); +}); + +test('requesting a timeout adds an error when no set is in progress', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ]) + ->call('requestTimeout') + ->assertHasErrors('timeout') + ->assertNotDispatched('game-event-recorded') + ->assertNotDispatched('timeout-recorded'); +}); + +test('substitution card shows modal trigger when a set is in progress and substitutions remain', function (): void { + $game = gameWithActiveSetForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ])->assertSeeHtml('data-team-roster-substitutions') + ->assertSeeHtml('substitution-team_a'); +}); + +test('substitution card is not a modal trigger when no set is in progress', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ])->assertSeeHtml('data-team-roster-substitutions') + ->assertDontSeeHtml('substitution-team_a'); +}); + +test('substitution card shows full modal trigger when all 6 substitutions are used', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $state = $game->stateAt(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => GameState::fromAttributes(array_merge($state->toAttributes(), ['substitutions_team_a' => 6])), + ])->assertSeeHtml('data-team-roster-substitutions') + ->assertSeeHtml("\$flux.modal('substitution-full-confirm-team_a').show()") + ->assertDontSeeHtml('name="substitution-team_a"'); +}); + +test('requesting a substitution records an improper request when all substitutions are used', function (): void { + $game = gameWithActiveSetForTeamRoster(); + recordSixSubstitutionsForTeamRoster($game); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->call('requestSubstitutionWhenFull') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDispatched('substitution-improper-request-recorded', team: 'team_a', delayWarning: false, delayPenalty: false); + + $freshGame = $game->fresh(); + $latestEvent = $freshGame->events->last(); + + expect($latestEvent->type)->toBe(GameEventType::ImproperRequestRecorded) + ->and($latestEvent->payload->requestType)->toBe(ImproperRequestType::Substitution) + ->and($freshGame->stateAt()->substitutionsTeamA)->toBe(6) + ->and($freshGame->stateAt()->improperRequestsTeamA)->toBe(1); +}); + +test('second improper substitution request records a delay warning', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + recordSixSubstitutionsForTeamRoster($game); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->call('requestSubstitutionWhenFull') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDispatched('substitution-improper-request-recorded', team: 'team_a', delayWarning: true, delayPenalty: false); + + $freshGame = $game->fresh(); + $latestEvent = $freshGame->events->last(); + + expect($latestEvent->type)->toBe(GameEventType::DelayWarningRecorded) + ->and($latestEvent->payload->requestType)->toBe(ImproperRequestType::Substitution) + ->and($freshGame->stateAt()->improperRequestsTeamA)->toBe(1) + ->and($freshGame->stateAt()->delayWarningsTeamA)->toBe(1); +}); + +test('third improper substitution request records a delay penalty', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Substitution); + recordSixSubstitutionsForTeamRoster($game); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->call('requestSubstitutionWhenFull') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDispatched('substitution-improper-request-recorded', team: 'team_a', delayWarning: false, delayPenalty: true); + + $freshGame = $game->fresh(); + $latestEvent = $freshGame->events->last(); + + expect($latestEvent->type)->toBe(GameEventType::DelayPenaltyRecorded) + ->and($latestEvent->payload->requestType)->toBe(ImproperRequestType::Substitution) + ->and($freshGame->stateAt()->scoreTeamB)->toBe(1) + ->and($freshGame->stateAt()->delayPenaltiesTeamA)->toBe(1); +}); + +test('substitution modal shows on-court and bench player numbers', function (): void { + $game = gameWithActiveSetForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ])->assertSeeHtml('data-substitution-on-court-number="1"') + ->assertSeeHtml('data-substitution-on-court-number="6"') + ->assertDontSeeHtml('data-substitution-bench-number="1"'); +}); + +test('substitution records event and dispatches events', function (): void { + $game = gameWithActiveSetForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->set('playerOut', '1') + ->set('playerIn', '7') + ->call('submitSubstitution') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDispatched('substitution-recorded', team: 'team_a'); + + $latestEvent = $game->fresh()->events->last(); + expect($latestEvent->type)->toBe(GameEventType::SubstitutionCompleted); +}); + +test('substitution requires both player numbers', function (): void { + $game = gameWithActiveSetForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->set('playerOut', '') + ->set('playerIn', '') + ->call('submitSubstitution') + ->assertHasErrors('substitution') + ->assertNotDispatched('game-event-recorded'); +}); + +test('substitution is rejected when team has used all 6 substitutions', function (): void { + $game = gameWithActiveSetForTeamRoster(); + + for ($i = 0; $i < 6; $i++) { + $game->recordSubstitution(TeamAB::TeamA, $i + 1, $i + 7); + } + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->set('playerOut', '1') + ->set('playerIn', '7') + ->call('submitSubstitution') + ->assertHasErrors('substitution') + ->assertNotDispatched('game-event-recorded'); +}); + +test('substituted player cannot be substituted again in the same set', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordSubstitution(TeamAB::TeamA, 1, 7); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->set('playerOut', '7') + ->set('playerIn', '1') + ->call('submitSubstitution') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded'); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->fresh()->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->fresh()->stateAt(), + ]) + ->set('playerOut', '1') + ->set('playerIn', '7') + ->call('submitSubstitution') + ->assertHasErrors('substitution') + ->assertNotDispatched('game-event-recorded'); +}); + +test('substitution pair can only swap back once', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordSubstitution(TeamAB::TeamA, 1, 7); + $game->recordSubstitution(TeamAB::TeamA, 7, 1); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->set('playerOut', '1') + ->set('playerIn', '7') + ->call('submitSubstitution') + ->assertHasErrors('substitution') + ->assertNotDispatched('game-event-recorded'); +}); + +test('player with active pair constraint can only be replaced by their partner', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordSubstitution(TeamAB::TeamA, 1, 7); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->set('playerOut', '7') + ->set('playerIn', '8') + ->call('submitSubstitution') + ->assertHasErrors('substitution') + ->assertNotDispatched('game-event-recorded'); +}); + +test('substitution constraints reset between sets', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordSubstitution(TeamAB::TeamA, 1, 7); + $game->recordSubstitution(TeamAB::TeamA, 7, 1); + + for ($i = 0; $i < 25; $i++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordLineup(2, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(2, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->set('playerOut', '1') + ->set('playerIn', '7') + ->call('submitSubstitution') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded'); +}); + +function gameWithActiveSetForTeamRoster(): Game +{ + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + $game->recordLineup(1, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(1, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + + return $game; +} + +function recordSixSubstitutionsForTeamRoster(Game $game): void +{ + $game->recordSubstitution(TeamAB::TeamA, 1, 7); + $game->recordSubstitution(TeamAB::TeamA, 7, 1); + $game->recordSubstitution(TeamAB::TeamA, 2, 8); + $game->recordSubstitution(TeamAB::TeamA, 8, 2); + $game->recordSubstitution(TeamAB::TeamA, 3, 9); + $game->recordSubstitution(TeamAB::TeamA, 9, 3); +} + +function gameWithNumberedRostersForTeamRoster(): Game +{ + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayerOne = Player::factory()->for($homeTeam)->named('Anna', 'Zephyr')->create(); + $homePlayerTwo = Player::factory()->for($homeTeam)->named('Beth', 'Anderson')->create(); + $homeLibero = Player::factory()->for($homeTeam)->named('Cara', 'Libero')->create(); + + $awayPlayerOne = Player::factory()->for($awayTeam)->named('Dora', 'Young')->create(); + $awayPlayerTwo = Player::factory()->for($awayTeam)->named('Etta', 'Baker')->create(); + $awayLibero = Player::factory()->for($awayTeam)->named('Faye', 'Keeper')->create(); + $homeCoach = Staff::factory()->for($homeTeam)->asCoach()->create(); + $homeAssistantCoachOne = Staff::factory()->for($homeTeam)->asAssistantCoach()->create(); + $homeAssistantCoachTwo = Staff::factory()->for($homeTeam)->asAssistantCoach()->create(); + $homeDoctor = Staff::factory()->for($homeTeam)->asDoctor()->create(); + $homeTherapist = Staff::factory()->for($homeTeam)->asTherapist()->create(); + $awayCoach = Staff::factory()->for($awayTeam)->asCoach()->create(); + $awayAssistantCoachOne = Staff::factory()->for($awayTeam)->asAssistantCoach()->create(); + $awayAssistantCoachTwo = Staff::factory()->for($awayTeam)->asAssistantCoach()->create(); + $awayDoctor = Staff::factory()->for($awayTeam)->asDoctor()->create(); + $awayTherapist = Staff::factory()->for($awayTeam)->asTherapist()->create(); + + $game->addPlayer($homePlayerOne, number: 12); + $game->addPlayer($homePlayerTwo, number: 3); + $game->addPlayer($homeLibero, number: 1, isLibero: true); + $game->addPlayer($awayPlayerOne, number: 9); + $game->addPlayer($awayPlayerTwo, number: 2); + $game->addPlayer($awayLibero, number: 20, isLibero: true); + $game->addStaff($homeCoach, StaffRole::Coach); + $game->addStaff($homeAssistantCoachOne, StaffRole::AssistantCoach); + $game->addStaff($homeAssistantCoachTwo, StaffRole::AssistantCoach); + $game->addStaff($homeDoctor, StaffRole::Doctor); + $game->addStaff($homeTherapist, StaffRole::Therapist); + $game->addStaff($awayCoach, StaffRole::Coach); + $game->addStaff($awayAssistantCoachOne, StaffRole::AssistantCoach); + $game->addStaff($awayAssistantCoachTwo, StaffRole::AssistantCoach); + $game->addStaff($awayDoctor, StaffRole::Doctor); + $game->addStaff($awayTherapist, StaffRole::Therapist); + + return $game; +} + +function submittedLineupState(array $attributes = []): GameState +{ + return GameState::fromAttributes(array_merge([ + 'rotation_team_a' => [1 => 999], + 'rotation_team_b' => [1 => 998], + ], $attributes)); +} diff --git a/tests/Feature/TossResultSubmissionTest.php b/tests/Feature/TossResultSubmissionTest.php new file mode 100644 index 0000000..ab45ff2 --- /dev/null +++ b/tests/Feature/TossResultSubmissionTest.php @@ -0,0 +1,229 @@ +set('game.between_sets_duration', 0); +}); + +test('submitting toss result creates a toss completed event', function (): void { + $game = Game::factory()->create(); + seedTossResultRosterableTeams($game); + + Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + ->set('teamA', TeamSide::Away->value) + ->set('serving', TeamSide::Home->value) + ->call('submit') + ->assertHasNoErrors(); + + $event = $game->fresh()->events->first(); + + expect($event)->not->toBeNull() + ->and($event->type)->toBe(GameEventType::TossCompleted) + ->and($event->payload->teamA)->toBe(TeamSide::Away) + ->and($event->payload->leftTeam)->toBe(TeamAB::TeamA) + ->and($event->payload->serving)->toBe(TeamAB::TeamB); +}); + +test('submitting fifth set toss keeps team assignment and records left and serving teams', function (): void { + $game = tiedGameReadyForFifthSetToss(); + + Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + ->assertSee('Submit Fifth Set Toss') + ->set('left', TeamSide::Away->value) + ->set('serving', TeamSide::Away->value) + ->call('submit') + ->assertHasNoErrors(); + + $event = $game->fresh()->events->last(); + $state = $game->fresh()->stateAt(); + + expect($event)->not->toBeNull() + ->and($event->type)->toBe(GameEventType::TossCompleted) + ->and($event->payload->teamA)->toBe(TeamSide::Home) + ->and($event->payload->leftTeam)->toBe(TeamAB::TeamB) + ->and($event->payload->serving)->toBe(TeamAB::TeamB) + ->and($state->fifthSetLeftTeam)->toBe(TeamAB::TeamB) + ->and($state->servingTeam)->toBe(TeamAB::TeamB); +}); + +test('submitting toss result fails when there is no active game', function (): void { + Livewire::test(TossResultSubmission::class) + ->call('submit') + ->assertHasErrors(['submit']); +}); + +test('submitting toss result fails when toss has already been recorded', function (): void { + $game = Game::factory()->create(); + seedTossResultRosterableTeams($game); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + ->set('teamA', TeamSide::Away->value) + ->set('serving', TeamSide::Home->value) + ->call('submit') + ->assertHasErrors(['submit']); + + expect($game->fresh()->events)->toHaveCount(1); +}); + +test('submitting toss result records the event against the provided game id', function (): void { + $targetGame = Game::factory()->create(); + $otherGame = Game::factory()->create(); + seedTossResultRosterableTeams($targetGame); + + Livewire::test(TossResultSubmission::class, ['gameId' => $targetGame->getKey()]) + ->set('teamA', TeamSide::Away->value) + ->set('serving', TeamSide::Away->value) + ->call('submit') + ->assertHasNoErrors(); + + expect($targetGame->fresh()->events)->toHaveCount(1) + ->and($targetGame->fresh()->events->first()?->payload->serving)->toBe(TeamAB::TeamA) + ->and($otherGame->fresh()->events)->toHaveCount(0); +}); + +test('toss modal shows home and away team country codes', function (): void { + $game = tiedGameReadyForFifthSetToss(); + + Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + ->assertSee($game->homeTeam->country_code) + ->assertSee($game->awayTeam->country_code) + ->assertDontSee('Home Team') + ->assertDontSee('Away Team'); +}); + +test('initial toss submission fails until rosters have been submitted', function (): void { + $game = Game::factory()->create(); + + Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + ->set('teamA', TeamSide::Away->value) + ->set('serving', TeamSide::Home->value) + ->call('submit') + ->assertHasErrors(['submit']); +}); + +test('toss submit button is hidden when toss has already been recorded', function (): void { + $game = Game::factory()->create(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + ->assertDontSee('Submit Toss Result') + ->assertDontSee('Save Toss Result'); +}); + +test('toss submit button is hidden when snapshot state already includes toss data', function (): void { + $game = Game::factory()->create(); + + $stateEvent = GameEvent::withoutEvents(fn (): GameEvent => GameEvent::query()->create([ + 'game_id' => $game->getKey(), + 'type' => GameEventType::SetStarted, + 'payload' => new SetStartedPayload, + 'created_at' => Carbon::now(), + ])); + + GameStateSnapshot::query()->create([ + 'game_id' => $game->getKey(), + 'game_event_id' => $stateEvent->getKey(), + 'set_number' => 0, + 'score_team_a' => 0, + 'score_team_b' => 0, + 'sets_won_team_a' => 0, + 'sets_won_team_b' => 0, + 'timeouts_team_a' => 0, + 'timeouts_team_b' => 0, + 'substitutions_team_a' => 0, + 'substitutions_team_b' => 0, + 'team_a_side' => TeamSide::Away->value, + 'serving_team' => TeamAB::TeamB->value, + 'rotation_team_a' => [], + 'rotation_team_b' => [], + 'set_in_progress' => false, + 'game_ended' => false, + 'created_at' => Carbon::now(), + ]); + + Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + ->assertDontSee('Submit Toss Result') + ->assertDontSee('Save Toss Result'); +}); + +function seedTossResultRosterableTeams(Game $game): void +{ + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->call('confirmRosters') + ->assertHasNoErrors(); +} + +function tiedGameReadyForFifthSetToss(): Game +{ + $game = Game::factory()->create(); + + for ($index = 0; $index < 6; $index++) { + $homePlayer = Player::factory()->for($game->homeTeam)->create(); + $awayPlayer = Player::factory()->for($game->awayTeam)->create(); + $game->addPlayer($homePlayer, number: $index + 1); + $game->addPlayer($awayPlayer, number: $index + 11); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + $set = $game->stateAt()->setNumber + 1; + $game->recordLineup($set, TeamAB::TeamA, tossResultLineupPositions(1)); + $game->recordLineup($set, TeamAB::TeamB, tossResultLineupPositions(11)); + $game->recordSetStarted(); + + for ($rally = 0; $rally < 25; $rally++) { + $game->recordRallyWinner($setWinner); + } + } + + return $game->fresh(); +} + +/** + * @return array + */ +function tossResultLineupPositions(int $start): array +{ + return [ + 1 => $start, + 2 => $start + 1, + 3 => $start + 2, + 4 => $start + 3, + 5 => $start + 4, + 6 => $start + 5, + ]; +} diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 963bc0c..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -});