From d6e5455e900a73fc90a520ae54d37a12cc371ec8 Mon Sep 17 00:00:00 2001 From: Twyco Date: Tue, 25 Aug 2026 22:23:38 +0200 Subject: [PATCH] Implement instant websocket room updates --- .ai/rules/index.md | 7 ++ .ai/rules/js-composables.md | 9 ++ app/Events/RoomStateChanged.php | 11 +- app/Http/Controllers/RoundController.php | 14 +-- app/Http/Controllers/VoteController.php | 6 +- app/Services/Rooms/RoomLifecycleService.php | 5 + .../Rooms/RoomParticipationService.php | 5 + .../Rooms/RoomRealtimeStateService.php | 64 ++++++++++ app/Services/Rooms/RoundService.php | 7 ++ app/Services/Rooms/VoteService.php | 5 + .../rooms/RoomModeratorControls.vue | 39 +++--- .../js/components/rooms/RoomVoteDeck.vue | 36 +++--- resources/js/composables/useRoomRealtime.ts | 114 ++++++++++-------- resources/js/pages/rooms/Show.vue | 39 +++--- resources/js/types/room.ts | 14 +++ tests/Feature/RoomBroadcastingTest.php | 46 +++++-- tests/Feature/RoomLifecycleTest.php | 2 +- tests/Feature/RoomVotingTest.php | 10 +- 18 files changed, 303 insertions(+), 130 deletions(-) create mode 100644 .ai/rules/index.md create mode 100644 .ai/rules/js-composables.md create mode 100644 app/Services/Rooms/RoomRealtimeStateService.php diff --git a/.ai/rules/index.md b/.ai/rules/index.md new file mode 100644 index 0000000..ce851f4 --- /dev/null +++ b/.ai/rules/index.md @@ -0,0 +1,7 @@ +# Project Rules Index + +Before planning or editing, find the row whose globs match the file's path and read that rule file. + +| Applies to | Rule file | +| --- | --- | +| {app/Events/**,app/Services/Rooms/**,resources/js/composables/**} | .ai/rules/js-composables.md | diff --git a/.ai/rules/js-composables.md b/.ai/rules/js-composables.md new file mode 100644 index 0000000..98e4c56 --- /dev/null +++ b/.ai/rules/js-composables.md @@ -0,0 +1,9 @@ +--- +paths: + - '{app/Events/**,app/Services/Rooms/**,resources/js/composables/**}' +--- + +# Js Composables + +## Never broadcast hidden room votes +Room table updates are delivered as synchronous WebSocket state payloads without polling or Inertia reloads. Before a round is revealed, broadcast only has_voted and keep every player's vote null; actual vote values may enter the shared payload only after reveal. State-changing commands remain server-authorized HTTP requests. diff --git a/app/Events/RoomStateChanged.php b/app/Events/RoomStateChanged.php index 8d13377..2c9a8da 100644 --- a/app/Events/RoomStateChanged.php +++ b/app/Events/RoomStateChanged.php @@ -5,18 +5,22 @@ namespace App\Events; use App\Enums\RoomStateChange; use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class RoomStateChanged implements ShouldBroadcast +class RoomStateChanged implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; + /** + * @param array|null $state + */ public function __construct( public readonly string $roomId, public readonly int $version, public readonly RoomStateChange $change, + public readonly ?array $state = null, ) {} public function broadcastOn(): Channel @@ -30,13 +34,14 @@ class RoomStateChanged implements ShouldBroadcast } /** - * @return array{version: int, change: string} + * @return array{version: int, change: string, state: array|null} */ public function broadcastWith(): array { return [ 'version' => $this->version, 'change' => $this->change->value, + 'state' => $this->state, ]; } } diff --git a/app/Http/Controllers/RoundController.php b/app/Http/Controllers/RoundController.php index 2939832..674e080 100644 --- a/app/Http/Controllers/RoundController.php +++ b/app/Http/Controllers/RoundController.php @@ -5,8 +5,8 @@ namespace App\Http\Controllers; use App\Models\Player; use App\Models\Room; use App\Services\Rooms\RoundService; -use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Http\Response; class RoundController extends Controller { @@ -14,28 +14,28 @@ class RoundController extends Controller private readonly RoundService $rounds, ) {} - public function reset(Request $request, Room $room): RedirectResponse + public function reset(Request $request, Room $room): Response { $this->ensureModerator($request, $room); $this->rounds->reset($room); - return back(); + return response()->noContent(); } - public function reveal(Request $request, Room $room): RedirectResponse + public function reveal(Request $request, Room $room): Response { $this->ensureModerator($request, $room); $this->rounds->reveal($room); - return back(); + return response()->noContent(); } - public function nextRound(Request $request, Room $room): RedirectResponse + public function nextRound(Request $request, Room $room): Response { $this->ensureModerator($request, $room); $this->rounds->startNext($room); - return back(); + return response()->noContent(); } private function ensureModerator(Request $request, Room $room): void diff --git a/app/Http/Controllers/VoteController.php b/app/Http/Controllers/VoteController.php index 8e78257..8f898f3 100644 --- a/app/Http/Controllers/VoteController.php +++ b/app/Http/Controllers/VoteController.php @@ -6,7 +6,7 @@ use App\Http\Requests\SubmitVoteRequest; use App\Models\Player; use App\Models\Room; use App\Services\Rooms\VoteService; -use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Response; class VoteController extends Controller { @@ -17,7 +17,7 @@ class VoteController extends Controller public function store( SubmitVoteRequest $request, Room $room, - ): RedirectResponse { + ): Response { $player = $request->attributes->get('currentPlayer'); abort_unless( @@ -32,6 +32,6 @@ class VoteController extends Controller $request->validated('value'), ); - return back(); + return response()->noContent(); } } diff --git a/app/Services/Rooms/RoomLifecycleService.php b/app/Services/Rooms/RoomLifecycleService.php index 62dd384..0ebe284 100644 --- a/app/Services/Rooms/RoomLifecycleService.php +++ b/app/Services/Rooms/RoomLifecycleService.php @@ -11,6 +11,10 @@ use Illuminate\Support\Facades\DB; class RoomLifecycleService { + public function __construct( + private readonly RoomRealtimeStateService $realtimeState, + ) {} + /** * @return array{player: Player, was_reopened: bool} */ @@ -49,6 +53,7 @@ class RoomLifecycleService $room->id, $stateVersion, RoomStateChange::RoomReopened, + $this->realtimeState->for($room), ); } diff --git a/app/Services/Rooms/RoomParticipationService.php b/app/Services/Rooms/RoomParticipationService.php index f880db7..0805efb 100644 --- a/app/Services/Rooms/RoomParticipationService.php +++ b/app/Services/Rooms/RoomParticipationService.php @@ -12,6 +12,10 @@ use Illuminate\Validation\ValidationException; class RoomParticipationService { + public function __construct( + private readonly RoomRealtimeStateService $realtimeState, + ) {} + public function findByJoinCode(string $joinCode): ?Room { if ($joinCode === '') { @@ -92,6 +96,7 @@ class RoomParticipationService $room->id, $room->state_version, RoomStateChange::PlayerJoined, + $this->realtimeState->for($room), ); return [$room, $player]; diff --git a/app/Services/Rooms/RoomRealtimeStateService.php b/app/Services/Rooms/RoomRealtimeStateService.php new file mode 100644 index 0000000..7871845 --- /dev/null +++ b/app/Services/Rooms/RoomRealtimeStateService.php @@ -0,0 +1,64 @@ + + * } + */ + public function for(Room $room): array + { + $room->refresh(); + $room->load([ + 'players' => fn ($query) => $query + ->active() + ->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at']) + ->orderByDesc('is_moderator') + ->orderBy('created_at'), + 'players.votes' => fn ($query) => $query + ->where('room_id', $room->id) + ->where('round', $room->current_round), + ]); + + return [ + 'current_round' => $room->current_round, + 'revealed' => $room->revealed_at !== null, + 'state_version' => $room->state_version, + 'statistics' => $this->statistics->currentRound($room), + 'players' => $room->players + ->map(function ($player) use ($room): array { + $vote = $player->votes->first(); + + return [ + 'id' => $player->id, + 'name' => $player->name, + 'is_moderator' => $player->is_moderator, + 'is_owner' => $player->user_id === $room->owner_id, + 'has_voted' => $vote !== null, + 'vote' => $room->revealed_at !== null ? $vote?->value : null, + ]; + }) + ->all(), + ]; + } +} diff --git a/app/Services/Rooms/RoundService.php b/app/Services/Rooms/RoundService.php index 2e3a1b2..f958b03 100644 --- a/app/Services/Rooms/RoundService.php +++ b/app/Services/Rooms/RoundService.php @@ -11,6 +11,10 @@ use Illuminate\Validation\ValidationException; class RoundService { + public function __construct( + private readonly RoomRealtimeStateService $realtimeState, + ) {} + public function reset(Room $room): void { $stateVersion = DB::transaction(function () use ($room): int { @@ -38,6 +42,7 @@ class RoundService $room->id, $stateVersion, RoomStateChange::VotesReset, + $this->realtimeState->for($room), ); } @@ -69,6 +74,7 @@ class RoundService $room->id, $stateVersion, RoomStateChange::CardsRevealed, + $this->realtimeState->for($room), ); } @@ -101,6 +107,7 @@ class RoundService $room->id, $stateVersion, RoomStateChange::NextRound, + $this->realtimeState->for($room), ); } diff --git a/app/Services/Rooms/VoteService.php b/app/Services/Rooms/VoteService.php index 207e613..8385056 100644 --- a/app/Services/Rooms/VoteService.php +++ b/app/Services/Rooms/VoteService.php @@ -12,6 +12,10 @@ use Illuminate\Validation\ValidationException; class VoteService { + public function __construct( + private readonly RoomRealtimeStateService $realtimeState, + ) {} + public function submit(Room $room, Player $player, string $value): void { $stateVersion = DB::transaction(function () use ($room, $player, $value): int { @@ -52,6 +56,7 @@ class VoteService $room->id, $stateVersion, RoomStateChange::VoteChanged, + $this->realtimeState->for($room), ); } } diff --git a/resources/js/components/rooms/RoomModeratorControls.vue b/resources/js/components/rooms/RoomModeratorControls.vue index 4747577..57a102a 100644 --- a/resources/js/components/rooms/RoomModeratorControls.vue +++ b/resources/js/components/rooms/RoomModeratorControls.vue @@ -1,5 +1,5 @@ @@ -35,7 +38,7 @@ function resetVotes(): void {