Implement instant websocket room updates

This commit is contained in:
2026-08-25 22:23:38 +02:00
parent bfc93dceba
commit d6e5455e90
18 changed files with 303 additions and 130 deletions
+7
View File
@@ -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 |
+9
View File
@@ -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.
+8 -3
View File
@@ -5,18 +5,22 @@ namespace App\Events;
use App\Enums\RoomStateChange; use App\Enums\RoomStateChange;
use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class RoomStateChanged implements ShouldBroadcast class RoomStateChanged implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param array<string, mixed>|null $state
*/
public function __construct( public function __construct(
public readonly string $roomId, public readonly string $roomId,
public readonly int $version, public readonly int $version,
public readonly RoomStateChange $change, public readonly RoomStateChange $change,
public readonly ?array $state = null,
) {} ) {}
public function broadcastOn(): Channel 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<string, mixed>|null}
*/ */
public function broadcastWith(): array public function broadcastWith(): array
{ {
return [ return [
'version' => $this->version, 'version' => $this->version,
'change' => $this->change->value, 'change' => $this->change->value,
'state' => $this->state,
]; ];
} }
} }
+7 -7
View File
@@ -5,8 +5,8 @@ namespace App\Http\Controllers;
use App\Models\Player; use App\Models\Player;
use App\Models\Room; use App\Models\Room;
use App\Services\Rooms\RoundService; use App\Services\Rooms\RoundService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response;
class RoundController extends Controller class RoundController extends Controller
{ {
@@ -14,28 +14,28 @@ class RoundController extends Controller
private readonly RoundService $rounds, 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->ensureModerator($request, $room);
$this->rounds->reset($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->ensureModerator($request, $room);
$this->rounds->reveal($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->ensureModerator($request, $room);
$this->rounds->startNext($room); $this->rounds->startNext($room);
return back(); return response()->noContent();
} }
private function ensureModerator(Request $request, Room $room): void private function ensureModerator(Request $request, Room $room): void
+3 -3
View File
@@ -6,7 +6,7 @@ use App\Http\Requests\SubmitVoteRequest;
use App\Models\Player; use App\Models\Player;
use App\Models\Room; use App\Models\Room;
use App\Services\Rooms\VoteService; use App\Services\Rooms\VoteService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\Response;
class VoteController extends Controller class VoteController extends Controller
{ {
@@ -17,7 +17,7 @@ class VoteController extends Controller
public function store( public function store(
SubmitVoteRequest $request, SubmitVoteRequest $request,
Room $room, Room $room,
): RedirectResponse { ): Response {
$player = $request->attributes->get('currentPlayer'); $player = $request->attributes->get('currentPlayer');
abort_unless( abort_unless(
@@ -32,6 +32,6 @@ class VoteController extends Controller
$request->validated('value'), $request->validated('value'),
); );
return back(); return response()->noContent();
} }
} }
@@ -11,6 +11,10 @@ use Illuminate\Support\Facades\DB;
class RoomLifecycleService class RoomLifecycleService
{ {
public function __construct(
private readonly RoomRealtimeStateService $realtimeState,
) {}
/** /**
* @return array{player: Player, was_reopened: bool} * @return array{player: Player, was_reopened: bool}
*/ */
@@ -49,6 +53,7 @@ class RoomLifecycleService
$room->id, $room->id,
$stateVersion, $stateVersion,
RoomStateChange::RoomReopened, RoomStateChange::RoomReopened,
$this->realtimeState->for($room),
); );
} }
@@ -12,6 +12,10 @@ use Illuminate\Validation\ValidationException;
class RoomParticipationService class RoomParticipationService
{ {
public function __construct(
private readonly RoomRealtimeStateService $realtimeState,
) {}
public function findByJoinCode(string $joinCode): ?Room public function findByJoinCode(string $joinCode): ?Room
{ {
if ($joinCode === '') { if ($joinCode === '') {
@@ -92,6 +96,7 @@ class RoomParticipationService
$room->id, $room->id,
$room->state_version, $room->state_version,
RoomStateChange::PlayerJoined, RoomStateChange::PlayerJoined,
$this->realtimeState->for($room),
); );
return [$room, $player]; return [$room, $player];
@@ -0,0 +1,64 @@
<?php
namespace App\Services\Rooms;
use App\Models\Room;
class RoomRealtimeStateService
{
public function __construct(
private readonly RoomStatisticsService $statistics,
) {}
/**
* @return array{
* current_round: int,
* revealed: bool,
* state_version: int,
* statistics: array{average: string|null, median: string|null}|null,
* players: array<int, array{
* id: int,
* name: string,
* is_moderator: bool,
* is_owner: bool,
* has_voted: bool,
* vote: string|null
* }>
* }
*/
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(),
];
}
}
+7
View File
@@ -11,6 +11,10 @@ use Illuminate\Validation\ValidationException;
class RoundService class RoundService
{ {
public function __construct(
private readonly RoomRealtimeStateService $realtimeState,
) {}
public function reset(Room $room): void public function reset(Room $room): void
{ {
$stateVersion = DB::transaction(function () use ($room): int { $stateVersion = DB::transaction(function () use ($room): int {
@@ -38,6 +42,7 @@ class RoundService
$room->id, $room->id,
$stateVersion, $stateVersion,
RoomStateChange::VotesReset, RoomStateChange::VotesReset,
$this->realtimeState->for($room),
); );
} }
@@ -69,6 +74,7 @@ class RoundService
$room->id, $room->id,
$stateVersion, $stateVersion,
RoomStateChange::CardsRevealed, RoomStateChange::CardsRevealed,
$this->realtimeState->for($room),
); );
} }
@@ -101,6 +107,7 @@ class RoundService
$room->id, $room->id,
$stateVersion, $stateVersion,
RoomStateChange::NextRound, RoomStateChange::NextRound,
$this->realtimeState->for($room),
); );
} }
+5
View File
@@ -12,6 +12,10 @@ use Illuminate\Validation\ValidationException;
class VoteService class VoteService
{ {
public function __construct(
private readonly RoomRealtimeStateService $realtimeState,
) {}
public function submit(Room $room, Player $player, string $value): void public function submit(Room $room, Player $player, string $value): void
{ {
$stateVersion = DB::transaction(function () use ($room, $player, $value): int { $stateVersion = DB::transaction(function () use ($room, $player, $value): int {
@@ -52,6 +56,7 @@ class VoteService
$room->id, $room->id,
$stateVersion, $stateVersion,
RoomStateChange::VoteChanged, RoomStateChange::VoteChanged,
$this->realtimeState->for($room),
); );
} }
} }
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useForm } from '@inertiajs/vue3'; import { useHttp } from '@inertiajs/vue3';
import { Eye, RotateCcw, Trash2 } from '@lucide/vue'; import { Eye, RotateCcw, Trash2 } from '@lucide/vue';
import { nextRound, reset as resetRound, reveal } from '@/routes/room'; import { nextRound, reset as resetRound, reveal } from '@/routes/room';
@@ -7,26 +7,29 @@ import type { RoomModeratorControlsProps } from '@/types/room';
const props = defineProps<RoomModeratorControlsProps>(); const props = defineProps<RoomModeratorControlsProps>();
const revealForm = useForm<{ room?: string }>({}); const revealRequest = useHttp<{ room?: string }>(
const nextRoundForm = useForm<{ room?: string }>({}); () => reveal(props.roomId),
const resetForm = useForm<{ room?: string }>({}); {},
);
const nextRoundRequest = useHttp<{ room?: string }>(
() => nextRound(props.roomId),
{},
);
const resetRequest = useHttp<{ room?: string }>(
() => resetRound(props.roomId),
{},
);
function revealCards(): void { function revealCards(): void {
revealForm.submit(reveal(props.roomId), { void revealRequest.submit();
preserveScroll: true,
});
} }
function startNextRound(): void { function startNextRound(): void {
nextRoundForm.submit(nextRound(props.roomId), { void nextRoundRequest.submit();
preserveScroll: true,
});
} }
function resetVotes(): void { function resetVotes(): void {
resetForm.submit(resetRound(props.roomId), { void resetRequest.submit();
preserveScroll: true,
});
} }
</script> </script>
@@ -35,7 +38,7 @@ function resetVotes(): void {
<button <button
type="button" type="button"
class="inline-flex items-center gap-2 rounded-xl border border-slate-600 bg-slate-800 px-4 py-2.5 font-semibold text-slate-200 transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40" class="inline-flex items-center gap-2 rounded-xl border border-slate-600 bg-slate-800 px-4 py-2.5 font-semibold text-slate-200 transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="resetForm.processing || !hasVotes" :disabled="resetRequest.processing || !hasVotes"
@click="resetVotes" @click="resetVotes"
> >
<Trash2 class="size-4" /> <Trash2 class="size-4" />
@@ -45,18 +48,18 @@ function resetVotes(): void {
<div class="moderator-controls"> <div class="moderator-controls">
<p <p
v-if="revealForm.errors.room || nextRoundForm.errors.room" v-if="revealRequest.errors.room || nextRoundRequest.errors.room"
class="max-w-64 rounded-lg bg-rose-950/90 px-3 py-2 text-right text-sm text-rose-200 shadow-lg" class="max-w-64 rounded-lg bg-rose-950/90 px-3 py-2 text-right text-sm text-rose-200 shadow-lg"
role="alert" role="alert"
> >
{{ revealForm.errors.room ?? nextRoundForm.errors.room }} {{ revealRequest.errors.room ?? nextRoundRequest.errors.room }}
</p> </p>
<button <button
v-if="!revealed" v-if="!revealed"
type="button" type="button"
class="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-slate-100 px-5 py-2.5 font-semibold text-slate-900 transition hover:bg-white disabled:opacity-50" class="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-slate-100 px-5 py-2.5 font-semibold text-slate-900 transition hover:bg-white disabled:opacity-50"
:disabled="revealForm.processing" :disabled="revealRequest.processing"
@click="revealCards" @click="revealCards"
> >
<Eye class="size-4" /> <Eye class="size-4" />
@@ -67,7 +70,7 @@ function resetVotes(): void {
v-else v-else
type="button" type="button"
class="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-slate-100 px-5 py-2.5 font-semibold text-slate-900 transition hover:bg-white disabled:opacity-50" class="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-slate-100 px-5 py-2.5 font-semibold text-slate-900 transition hover:bg-white disabled:opacity-50"
:disabled="nextRoundForm.processing" :disabled="nextRoundRequest.processing"
@click="startNextRound" @click="startNextRound"
> >
<RotateCcw class="size-4" /> <RotateCcw class="size-4" />
+20 -16
View File
@@ -1,13 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { useForm } from '@inertiajs/vue3'; import { useHttp } from '@inertiajs/vue3';
import { Keyboard } from '@lucide/vue'; import { Keyboard } from '@lucide/vue';
import { useEventListener } from '@vueuse/core'; import { useEventListener } from '@vueuse/core';
import { watch } from 'vue'; import { watch } from 'vue';
import { store as storeVote } from '@/routes/room/vote'; import { store as storeVote } from '@/routes/room/vote';
import type { RoomVoteDeckProps } from '@/types/room'; import type { RoomVoteDeckEmits, RoomVoteDeckProps } from '@/types/room';
const props = defineProps<RoomVoteDeckProps>(); const props = defineProps<RoomVoteDeckProps>();
const emit = defineEmits<RoomVoteDeckEmits>();
const cardShortcuts = [ const cardShortcuts = [
'1', '1',
@@ -25,29 +26,32 @@ const cardShortcuts = [
), ),
]; ];
const voteForm = useForm({ const voteRequest = useHttp(() => storeVote(props.roomId), {
value: props.currentVote ?? '', value: props.currentVote ?? '',
}); });
watch( watch(
() => props.currentVote, () => props.currentVote,
(value) => { (value) => {
if (!voteForm.processing) { if (!voteRequest.processing) {
voteForm.value = value ?? ''; voteRequest.value = value ?? '';
} }
}, },
); );
function submitVote(value: string): void { function submitVote(value: string): void {
if (props.revealed || voteForm.processing) { if (props.revealed || voteRequest.processing) {
return; return;
} }
voteForm.value = value; const previousVote = props.currentVote;
voteForm.submit(storeVote(props.roomId), {
preserveScroll: true, voteRequest.value = value;
emit('vote-changed', value);
void voteRequest.submit({
onError: () => { onError: () => {
voteForm.value = props.currentVote ?? ''; voteRequest.value = previousVote ?? '';
emit('vote-changed', previousVote);
}, },
}); });
} }
@@ -71,7 +75,7 @@ function handleKeyboardVote(event: KeyboardEvent): void {
if ( if (
!props.presenterMode || !props.presenterMode ||
props.revealed || props.revealed ||
voteForm.processing || voteRequest.processing ||
event.repeat || event.repeat ||
event.altKey || event.altKey ||
event.ctrlKey || event.ctrlKey ||
@@ -118,10 +122,10 @@ useEventListener('keydown', handleKeyboardVote);
class="deck-card" class="deck-card"
:class="{ :class="{
'is-selected': 'is-selected':
!presenterMode && voteForm.value === card.key, !presenterMode && voteRequest.value === card.key,
}" }"
:disabled="voteForm.processing" :disabled="voteRequest.processing"
:aria-pressed="voteForm.value === card.key" :aria-pressed="voteRequest.value === card.key"
:aria-label="`Karte ${card.label} wählen`" :aria-label="`Karte ${card.label} wählen`"
@click="submitVote(card.key)" @click="submitVote(card.key)"
> >
@@ -138,11 +142,11 @@ useEventListener('keydown', handleKeyboardVote);
</div> </div>
<p <p
v-if="voteForm.errors.value" v-if="voteRequest.errors.value"
class="mt-3 text-sm text-rose-300" class="mt-3 text-sm text-rose-300"
role="alert" role="alert"
> >
{{ voteForm.errors.value }} {{ voteRequest.errors.value }}
</p> </p>
</section> </section>
</template> </template>
+63 -51
View File
@@ -1,72 +1,73 @@
import { router } from '@inertiajs/vue3'; import { router } from '@inertiajs/vue3';
import { onScopeDispose, watch } from 'vue'; import { onScopeDispose, ref, shallowReadonly, watch } from 'vue';
import { getEcho } from '@/lib/echo'; import { getEcho } from '@/lib/echo';
import type { RoomStateChangedPayload } from '@/types/room'; import { home } from '@/routes';
import { join } from '@/routes/room';
import type {
CurrentRoomPlayer,
PokerRoom,
RoomStateChangedPayload,
} from '@/types/room';
const ROOM_EVENT = '.room.state.changed'; const ROOM_EVENT = '.room.state.changed';
export function useRoomRealtime( export function useRoomRealtime(
roomId: () => string, initialRoom: () => PokerRoom,
stateVersion: () => number, initialCurrentPlayer: () => CurrentRoomPlayer,
): void { ) {
let reloadInProgress = false; const room = ref<PokerRoom>({ ...initialRoom() });
let pendingVersion = 0; const currentPlayer = ref<CurrentRoomPlayer>({ ...initialCurrentPlayer() });
let disposed = false;
function reloadRoom(): void {
if (reloadInProgress || disposed) {
return;
}
reloadInProgress = true;
router.reload({
only: ['room', 'currentPlayer'],
onFinish: () => {
reloadInProgress = false;
if (disposed) {
return;
}
if (pendingVersion > stateVersion()) {
reloadRoom();
return;
}
pendingVersion = stateVersion();
},
});
}
function handleRoomChange(payload: RoomStateChangedPayload): void { function handleRoomChange(payload: RoomStateChangedPayload): void {
if ( if (
typeof payload.version !== 'number' || typeof payload.version !== 'number' ||
payload.version <= Math.max(stateVersion(), pendingVersion) payload.version <= room.value.state_version
) { ) {
return; return;
} }
pendingVersion = payload.version;
if (payload.change === 'room_deleted') { if (payload.change === 'room_deleted') {
router.visit('/', { replace: true }); router.visit(home().url, { replace: true });
return; return;
} }
reloadRoom(); if (payload.change === 'room_closed') {
router.visit(
join({ query: { join_code: room.value.join_code } }).url,
{ replace: true },
);
return;
}
if (payload.state === null || payload.state === undefined) {
return;
}
room.value = {
...room.value,
...payload.state,
};
if (
payload.change === 'votes_reset' ||
payload.change === 'next_round'
) {
currentPlayer.value = {
...currentPlayer.value,
current_vote: null,
};
}
} }
const stopWatchingRoom = watch( const stopWatchingRoom = watch(
roomId, () => initialRoom().id,
(nextRoomId, _previousRoomId, onCleanup) => { (nextRoomId, _previousRoomId, onCleanup) => {
const echo = getEcho(); const echo = getEcho();
const channelName = `rooms.${nextRoomId}`; const channelName = `rooms.${nextRoomId}`;
pendingVersion = stateVersion();
echo.channel(channelName).listen(ROOM_EVENT, handleRoomChange); echo.channel(channelName).listen(ROOM_EVENT, handleRoomChange);
onCleanup(() => { onCleanup(() => {
@@ -76,19 +77,30 @@ export function useRoomRealtime(
{ immediate: true }, { immediate: true },
); );
const stopWatchingVersion = watch( const stopWatchingInitialState = watch(
stateVersion, [initialRoom, initialCurrentPlayer],
(version) => { ([nextRoom, nextCurrentPlayer]) => {
if (!reloadInProgress) { room.value = { ...nextRoom };
pendingVersion = Math.max(pendingVersion, version); currentPlayer.value = { ...nextCurrentPlayer };
}
}, },
{ flush: 'sync' }, { deep: true },
); );
onScopeDispose(() => { onScopeDispose(() => {
disposed = true;
stopWatchingRoom(); stopWatchingRoom();
stopWatchingVersion(); stopWatchingInitialState();
}); });
function setCurrentVote(value: string | null): void {
currentPlayer.value = {
...currentPlayer.value,
current_vote: value,
};
}
return {
room: shallowReadonly(room),
currentPlayer: shallowReadonly(currentPlayer),
setCurrentVote,
};
} }
+20 -19
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, usePage, usePoll } from '@inertiajs/vue3'; import { Head, usePage } from '@inertiajs/vue3';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import RoomHeader from '@/components/rooms/RoomHeader.vue'; import RoomHeader from '@/components/rooms/RoomHeader.vue';
@@ -18,18 +18,18 @@ const canFavorite = computed(
() => page.props.auth.user?.id != props.room.owner_id, () => page.props.auth.user?.id != props.room.owner_id,
); );
useRoomRealtime( const {
() => props.room.id, room: realtimeRoom,
() => props.room.state_version, currentPlayer: realtimeCurrentPlayer,
setCurrentVote,
} = useRoomRealtime(
() => props.room,
() => props.currentPlayer,
); );
usePoll(30_000, {
only: ['room', 'currentPlayer'],
});
</script> </script>
<template> <template>
<Head :title="room.title ?? 'Scrum Poker'" /> <Head :title="realtimeRoom.title ?? 'Scrum Poker'" />
<main <main
class="-m-6 min-h-[calc(100svh-4rem)] bg-slate-900 px-3 py-6 text-white sm:px-6 md:flex md:h-[calc(100dvh-4rem-1px)] md:min-h-0 md:flex-col md:overflow-hidden lg:px-10" class="-m-6 min-h-[calc(100svh-4rem)] bg-slate-900 px-3 py-6 text-white sm:px-6 md:flex md:h-[calc(100dvh-4rem-1px)] md:min-h-0 md:flex-col md:overflow-hidden lg:px-10"
@@ -37,26 +37,27 @@ usePoll(30_000, {
<RoomRetentionNotice v-if="roomNotice" :notice="roomNotice" /> <RoomRetentionNotice v-if="roomNotice" :notice="roomNotice" />
<RoomHeader <RoomHeader
:id="room.id" :id="realtimeRoom.id"
:title="room.title" :title="realtimeRoom.title"
:join_code="room.join_code" :join_code="realtimeRoom.join_code"
:is_favorite="room.is_favorite" :is_favorite="realtimeRoom.is_favorite"
:can-favorite="canFavorite" :can-favorite="canFavorite"
v-model:presenter-mode="presenterMode" v-model:presenter-mode="presenterMode"
/> />
<RoomTable <RoomTable
:room="room" :room="realtimeRoom"
:current-player="currentPlayer" :current-player="realtimeCurrentPlayer"
:presenter-mode="presenterMode" :presenter-mode="presenterMode"
/> />
<RoomVoteDeck <RoomVoteDeck
:room-id="room.id" :room-id="realtimeRoom.id"
:revealed="room.revealed" :revealed="realtimeRoom.revealed"
:cards="room.deck.cards" :cards="realtimeRoom.deck.cards"
:current-vote="currentPlayer.current_vote" :current-vote="realtimeCurrentPlayer.current_vote"
:presenter-mode="presenterMode" :presenter-mode="presenterMode"
@vote-changed="setCurrentVote"
/> />
</main> </main>
</template> </template>
+14
View File
@@ -132,6 +132,10 @@ export type RoomVoteDeckProps = {
presenterMode: boolean; presenterMode: boolean;
}; };
export type RoomVoteDeckEmits = {
'vote-changed': [value: string | null];
};
export type RoomStateChange = export type RoomStateChange =
| 'player_joined' | 'player_joined'
| 'vote_changed' | 'vote_changed'
@@ -145,4 +149,14 @@ export type RoomStateChange =
export type RoomStateChangedPayload = { export type RoomStateChangedPayload = {
version: number; version: number;
change: RoomStateChange; change: RoomStateChange;
state: RoomRealtimeState | null;
}; };
export type RoomRealtimeState = Pick<
PokerRoom,
| 'current_round'
| 'revealed'
| 'state_version'
| 'statistics'
| 'players'
>;
+39 -7
View File
@@ -5,6 +5,7 @@ use App\Events\RoomStateChanged;
use App\Jobs\CloseInactiveRooms; use App\Jobs\CloseInactiveRooms;
use App\Models\Room; use App\Models\Room;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
@@ -23,18 +24,20 @@ function createBroadcastRoom(array $attributes = []): Room
]); ]);
} }
test('room state broadcasts expose only invalidation metadata', function () { test('room state broadcasts synchronously expose sanitized state', function () {
$event = new RoomStateChanged( $event = new RoomStateChanged(
'019b8f80-37c4-71a3-b415-5f2697332c16', '019b8f80-37c4-71a3-b415-5f2697332c16',
7, 7,
RoomStateChange::VoteChanged, RoomStateChange::VoteChanged,
); );
expect($event->broadcastAs())->toBe('room.state.changed') expect($event)->toBeInstanceOf(ShouldBroadcastNow::class)
->and($event->broadcastAs())->toBe('room.state.changed')
->and($event->broadcastOn()->name)->toBe('rooms.019b8f80-37c4-71a3-b415-5f2697332c16') ->and($event->broadcastOn()->name)->toBe('rooms.019b8f80-37c4-71a3-b415-5f2697332c16')
->and($event->broadcastWith())->toBe([ ->and($event->broadcastWith())->toBe([
'version' => 7, 'version' => 7,
'change' => 'vote_changed', 'change' => 'vote_changed',
'state' => null,
]) ])
->and($event->broadcastWith())->not->toHaveKeys([ ->and($event->broadcastWith())->not->toHaveKeys([
'player_name', 'player_name',
@@ -68,23 +71,23 @@ test('voting and round actions broadcast their state changes', function () {
$this->withSession($session) $this->withSession($session)
->post(route('room.vote.store', $room), ['value' => '5']) ->post(route('room.vote.store', $room), ['value' => '5'])
->assertRedirect(); ->assertNoContent();
$this->withSession($session) $this->withSession($session)
->post(route('room.reveal', $room)) ->post(route('room.reveal', $room))
->assertRedirect(); ->assertNoContent();
$this->withSession($session) $this->withSession($session)
->post(route('room.next-round', $room)) ->post(route('room.next-round', $room))
->assertRedirect(); ->assertNoContent();
$this->withSession($session) $this->withSession($session)
->post(route('room.vote.store', $room), ['value' => '3']) ->post(route('room.vote.store', $room), ['value' => '3'])
->assertRedirect(); ->assertNoContent();
$this->withSession($session) $this->withSession($session)
->post(route('room.reset', $room)) ->post(route('room.reset', $room))
->assertRedirect(); ->assertNoContent();
foreach ([ foreach ([
RoomStateChange::VoteChanged, RoomStateChange::VoteChanged,
@@ -98,6 +101,35 @@ test('voting and round actions broadcast their state changes', function () {
); );
} }
Event::assertDispatched(
RoomStateChanged::class,
function (RoomStateChanged $event) use ($moderator): bool {
if ($event->change !== RoomStateChange::VoteChanged || $event->state === null) {
return false;
}
$player = collect($event->state['players'])->firstWhere('id', $moderator->id);
return $event->state['revealed'] === false
&& $player['has_voted'] === true
&& $player['vote'] === null;
},
);
Event::assertDispatched(
RoomStateChanged::class,
function (RoomStateChanged $event) use ($moderator): bool {
if ($event->change !== RoomStateChange::CardsRevealed || $event->state === null) {
return false;
}
$player = collect($event->state['players'])->firstWhere('id', $moderator->id);
return $event->state['revealed'] === true
&& $player['vote'] === '5';
},
);
Event::assertDispatchedTimes(RoomStateChanged::class, 5); Event::assertDispatchedTimes(RoomStateChanged::class, 5);
}); });
+1 -1
View File
@@ -238,7 +238,7 @@ test('meaningful game actions update the explicit activity timestamp', function
$this->withSession(["room_players.{$room->id}" => $moderator->id]) $this->withSession(["room_players.{$room->id}" => $moderator->id])
->post(route('room.vote.store', $room), ['value' => '5']) ->post(route('room.vote.store', $room), ['value' => '5'])
->assertRedirect(); ->assertNoContent();
expect($room->fresh()->last_activity_at->equalTo(now()))->toBeTrue(); expect($room->fresh()->last_activity_at->equalTo(now()))->toBeTrue();
}); });
+5 -5
View File
@@ -40,7 +40,7 @@ test('a participant can vote and change their vote in the current round', functi
$this->withSession($session) $this->withSession($session)
->post(route('room.vote.store', $room), ['value' => '5']) ->post(route('room.vote.store', $room), ['value' => '5'])
->assertRedirect(); ->assertNoContent();
$this->assertDatabaseHas('votes', [ $this->assertDatabaseHas('votes', [
'room_id' => $room->id, 'room_id' => $room->id,
@@ -51,7 +51,7 @@ test('a participant can vote and change their vote in the current round', functi
$this->withSession($session) $this->withSession($session)
->post(route('room.vote.store', $room), ['value' => '8']) ->post(route('room.vote.store', $room), ['value' => '8'])
->assertRedirect(); ->assertNoContent();
expect(Vote::query()->count())->toBe(1); expect(Vote::query()->count())->toBe(1);
expect(Vote::query()->value('value'))->toBe('8') expect(Vote::query()->value('value'))->toBe('8')
@@ -91,7 +91,7 @@ test('other votes stay hidden until the moderator reveals the round', function (
$this->withSession($session) $this->withSession($session)
->post(route('room.reveal', $room)) ->post(route('room.reveal', $room))
->assertRedirect(); ->assertNoContent();
expect($room->fresh()->last_activity_at->greaterThan($previousActivity))->toBeTrue(); expect($room->fresh()->last_activity_at->greaterThan($previousActivity))->toBeTrue();
@@ -123,7 +123,7 @@ test('starting the next round clears the revealed state', function () {
$this->withSession(["room_players.{$room->id}" => $moderator->id]) $this->withSession(["room_players.{$room->id}" => $moderator->id])
->post(route('room.next-round', $room)) ->post(route('room.next-round', $room))
->assertRedirect(); ->assertNoContent();
$room->refresh(); $room->refresh();
@@ -156,7 +156,7 @@ test('the moderator can reset only the votes from the current round', function (
$this->withSession(["room_players.{$room->id}" => $moderator->id]) $this->withSession(["room_players.{$room->id}" => $moderator->id])
->post(route('room.reset', $room)) ->post(route('room.reset', $room))
->assertRedirect(); ->assertNoContent();
$this->assertDatabaseHas('votes', [ $this->assertDatabaseHas('votes', [
'room_id' => $room->id, 'room_id' => $room->id,