Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
4c03902
feat: install Livewire 4
troccoli Mar 8, 2026
7853fba
feat: install Flux and Flux Pro
troccoli Mar 8, 2026
48a34f7
feat: implement front-end
troccoli Mar 8, 2026
cf74c2f
fix: updating the project's guidelines to run rector befor finalising…
troccoli Mar 8, 2026
676f2fa
fix: update composer and node packages
troccoli Mar 15, 2026
3677f53
feat: add roster on either side of the court
troccoli Mar 15, 2026
840a028
feat: implement lineup submission
troccoli Mar 19, 2026
4b2d89f
feat: add scoreboard
troccoli Mar 23, 2026
44f5e45
refactor: use the GameState object intead of array
troccoli Mar 23, 2026
848601c
refactor: extract scoreboard to its own component
troccoli Mar 23, 2026
d655b3b
feat: add serving player on court
troccoli Mar 25, 2026
82ea95b
feat: add button to start set and some refactoring
troccoli Mar 25, 2026
ba3f7ea
feat: add buttons to select the team who won the rally
troccoli Mar 27, 2026
e0884eb
fix: variouos fixes
troccoli Mar 27, 2026
5bf3f10
fix: move roster players under the court to simulate the benches
troccoli Mar 28, 2026
68ed1f9
refactor: moved things around
troccoli Mar 28, 2026
b512fa8
fix: fix order of staff on bench
troccoli Mar 28, 2026
19d1269
feat: add new seeder with controlled data
troccoli Mar 28, 2026
d8897c4
fix: variouos fixes
troccoli Mar 28, 2026
7849f03
feat: enforce lineup preconditions before starting sets
troccoli Mar 28, 2026
a138e15
fix: merged point 1 branch
troccoli Mar 28, 2026
c70bda5
refactor: centralize set scoring thresholds
troccoli Mar 28, 2026
1155895
refactor: reduce repeated Livewire data queries
troccoli Mar 28, 2026
65c9f20
Fix phpstan issues after point-3 merge
troccoli Mar 28, 2026
f5347bf
refactor: centralize team side mapping rules
troccoli Mar 28, 2026
d575d20
feat: improve responsive and accessible game controls
troccoli Mar 28, 2026
2d23c45
fix: variouos fixes
troccoli Mar 28, 2026
2f6fa97
fix: ensure all components use the game state snapshots
troccoli Mar 29, 2026
e66b03c
feat: extracted functionality for rally-won button
troccoli Mar 29, 2026
cc1310f
fix: exclude Unit tests as we don't have any
troccoli Mar 29, 2026
ebb0210
feat: ensure use of Flux UI component
troccoli Mar 29, 2026
98c85c7
fix: ensure serving team is on the correct side
troccoli Mar 29, 2026
55f97ae
fiz: move Winner button above court
troccoli Apr 3, 2026
4702d71
chore: rename CacheRepository as it does not deal with caching
troccoli Apr 28, 2026
abe05fe
chore: update to Laravel 13
troccoli Apr 28, 2026
9232252
chore: update Flux and Livewire
troccoli Apr 28, 2026
94eed44
chore: update Node packages
troccoli Apr 28, 2026
e4ace0b
feat: add cards for timeouts and substitutions
troccoli Apr 28, 2026
836391f
feat: move submit lineup buttons to the side of the benches
troccoli May 9, 2026
fab8530
feat: improved lineup submission
troccoli May 9, 2026
7f56935
fix: removed icons from buttons
troccoli May 9, 2026
c136ec9
feat: show set-end confirmation modal with score and winning team
troccoli May 9, 2026
8491745
chore: update packages
troccoli May 9, 2026
1fd9384
chore: fix styling
troccoli May 9, 2026
d029088
feat: move winner buttons to lineup submission slots below the court
troccoli May 10, 2026
3f19745
feat: add substitution modal with pairing validation
troccoli May 10, 2026
ef050c3
feat: show no-substitutions modal when team reaches the 6-sub limit
troccoli May 11, 2026
0b27cdb
feat: make timeout countdown duration configurable via GAME_TIMEOUT_D…
troccoli May 11, 2026
7a40df9
feat: enhance the ControlledGameSeeder seeder so that the game reache…
troccoli May 11, 2026
734b964
Handle fifth set toss side selection
troccoli May 12, 2026
3df50fa
Add between-set countdown flow
troccoli May 12, 2026
6d1c19b
Handle fifth-set side change
troccoli May 13, 2026
899274b
Fix roster modal interactions
troccoli May 13, 2026
1f90ef8
feat: add improper request for third time-out
troccoli May 22, 2026
f57745e
feat: add improper request for a seventh substitution
troccoli May 22, 2026
7b0b6f5
feat: add delay warning and delay penalty
troccoli May 22, 2026
9680e7e
feat: add misconduct recording and sanction controls
troccoli May 23, 2026
b9bcfdd
docs: add git commit guidelines
troccoli May 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .ai/guidelines/fluxui.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .ai/guidelines/git.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## Git

- Use Conventional Commits for commit messages.
- Format commit subjects as `<type>: <description>`, 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`.
4 changes: 2 additions & 2 deletions .ai/guidelines/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
133 changes: 130 additions & 3 deletions app/Data/GameState/GameState.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, int> */
public array $rotationTeamA = [];
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -58,10 +94,66 @@ public static function fromSnapshot(GameStateSnapshot $snapshot): self
return $state;
}

/**
* @param array<string, mixed> $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<string, mixed>
*/
public function toSnapshotAttributes(): array
public function toAttributes(): array
{
return [
'set_number' => $this->setNumber,
Expand All @@ -73,14 +165,44 @@ 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,
'game_ended' => $this->gameEnded,
];
}

/**
* @return array<string, mixed>
*/
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;
Expand All @@ -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;
}
}
5 changes: 5 additions & 0 deletions app/Enums/GameEventType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
11 changes: 11 additions & 0 deletions app/Enums/ImproperRequestType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum ImproperRequestType: string
{
case Timeout = 'timeout';
case Substitution = 'substitution';
}
13 changes: 13 additions & 0 deletions app/Enums/MisconductSanction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum MisconductSanction: string
{
case Warning = 'warning';
case Penalty = 'penalty';
case Expulsion = 'expulsion';
case Disqualification = 'disqualification';
}
11 changes: 11 additions & 0 deletions app/Enums/MisconductSubjectType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum MisconductSubjectType: string
{
case Player = 'player';
case Staff = 'staff';
}
7 changes: 7 additions & 0 deletions app/Enums/TeamAB.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,11 @@ enum TeamAB: string
{
case TeamA = 'team_a';
case TeamB = 'team_b';

public function label(): string
{
return $this === self::TeamA
? 'Team A'
: 'Team B';
}
}
20 changes: 20 additions & 0 deletions app/Events/Payloads/CourtSidesSwappedPayload.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App\Events\Payloads;

final readonly class CourtSidesSwappedPayload implements GameEventPayload
{
/** @param array<string, mixed> $data */
public static function fromArray(array $data): static
{
return new self;
}

/** @return array<string, mixed> */
public function toArray(): array
{
return [];
}
}
37 changes: 37 additions & 0 deletions app/Events/Payloads/DelayPenaltyRecordedPayload.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace App\Events\Payloads;

use App\Enums\ImproperRequestType;
use App\Enums\TeamAB;

final readonly class DelayPenaltyRecordedPayload implements GameEventPayload
{
public function __construct(
public TeamAB $team,
public TeamAB $awardedTeam,
public ImproperRequestType $requestType,
) {}

/** @param array<string, mixed> $data */
public static function fromArray(array $data): static
{
return new self(
team: TeamAB::from($data['team']),
awardedTeam: TeamAB::from($data['awarded_team']),
requestType: ImproperRequestType::from($data['request_type']),
);
}

/** @return array{team: string, awarded_team: string, request_type: string} */
public function toArray(): array
{
return [
'team' => $this->team->value,
'awarded_team' => $this->awardedTeam->value,
'request_type' => $this->requestType->value,
];
}
}
34 changes: 34 additions & 0 deletions app/Events/Payloads/DelayWarningRecordedPayload.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace App\Events\Payloads;

use App\Enums\ImproperRequestType;
use App\Enums\TeamAB;

final readonly class DelayWarningRecordedPayload implements GameEventPayload
{
public function __construct(
public TeamAB $team,
public ImproperRequestType $requestType,
) {}

/** @param array<string, mixed> $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,
];
}
}
Loading
Loading