feat: added laravel reverb and websocket, also emote throwing added

This commit is contained in:
2026-08-16 17:10:42 +02:00
parent 50e9fbd0b0
commit 6f47ed0674
30 changed files with 2244 additions and 34 deletions
+16 -1
View File
@@ -36,7 +36,7 @@ SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
@@ -66,3 +66,18 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
REVERB_APP_ID=local
REVERB_APP_KEY=local-key
REVERB_APP_SECRET=local-secret
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080
REVERB_HOST=localhost
REVERB_PORT=8080
REVERB_SCHEME=http
REVERB_ALLOWED_ORIGINS="localhost,127.0.0.1"
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Enums;
enum RoomStateChange: string
{
case PlayerJoined = 'player_joined';
case VoteChanged = 'vote_changed';
case CardsRevealed = 'cards_revealed';
case VotesReset = 'votes_reset';
case NextRound = 'next_round';
case RoomReopened = 'room_reopened';
case RoomClosed = 'room_closed';
case RoomDeleted = 'room_deleted';
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PaperBallThrown implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly string $id,
public readonly string $roomId,
public readonly int $fromPlayerId,
public readonly int $targetPlayerId,
) {}
public function broadcastOn(): Channel
{
return new Channel("rooms.{$this->roomId}");
}
public function broadcastAs(): string
{
return 'room.emote.thrown';
}
/**
* @return array{id: string, from_player_id: int, target_player_id: int}
*/
public function broadcastWith(): array
{
return [
'id' => $this->id,
'from_player_id' => $this->fromPlayerId,
'target_player_id' => $this->targetPlayerId,
];
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Events;
use App\Enums\RoomStateChange;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class RoomStateChanged implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly string $roomId,
public readonly int $version,
public readonly RoomStateChange $change,
) {}
public function broadcastOn(): Channel
{
return new Channel("rooms.{$this->roomId}");
}
public function broadcastAs(): string
{
return 'room.state.changed';
}
/**
* @return array{version: int, change: string}
*/
public function broadcastWith(): array
{
return [
'version' => $this->version,
'change' => $this->change->value,
];
}
}
+30 -5
View File
@@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Http\Requests\StoreRoomRequest;
use App\Models\Deck;
use App\Models\Player;
@@ -187,11 +189,13 @@ class RoomController extends Controller
abort_if($room->trashed(), 404);
abort_unless($user !== null && $user->id === $room->owner_id, 403);
$player = DB::transaction(function () use ($room, $user) {
[$player, $stateVersion, $wasReopened] = DB::transaction(function () use ($room, $user): array {
$lockedRoom = Room::query()->lockForUpdate()->findOrFail($room->id);
abort_unless($user->id === $lockedRoom->owner_id, 403);
$wasReopened = $lockedRoom->isClosed();
if ($lockedRoom->isClosed()) {
$lockedRoom->update([
'current_round' => $lockedRoom->current_round + 1,
@@ -202,13 +206,25 @@ class RoomController extends Controller
]);
}
return $lockedRoom->players()
->where('user_id', $user->id)
->firstOrFail();
return [
$lockedRoom->players()
->where('user_id', $user->id)
->firstOrFail(),
$lockedRoom->state_version,
$wasReopened,
];
});
$request->session()->put("room_players.{$room->id}", $player->id);
if ($wasReopened) {
RoomStateChanged::dispatch(
$room->id,
$stateVersion,
RoomStateChange::RoomReopened,
);
}
return to_route('room.show', $room);
}
@@ -219,7 +235,7 @@ class RoomController extends Controller
abort_if($room->trashed(), 404);
abort_unless($user !== null && $user->id === $room->owner_id, 403);
DB::transaction(function () use ($room, $user): void {
$stateVersion = DB::transaction(function () use ($room, $user): int {
$lockedRoom = Room::withTrashed()
->lockForUpdate()
->findOrFail($room->id);
@@ -231,9 +247,18 @@ class RoomController extends Controller
'state_version' => $lockedRoom->state_version + 1,
]);
$lockedRoom->delete();
return $lockedRoom->state_version;
});
$request->session()->forget("room_players.{$room->id}");
RoomStateChanged::dispatch(
$room->id,
$stateVersion,
RoomStateChange::RoomDeleted,
);
Inertia::flash('toast', [
'type' => 'success',
'message' => 'Der Raum wurde gelöscht.',
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers;
use App\Events\PaperBallThrown;
use App\Models\Player;
use App\Models\Room;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class RoomEmoteController extends Controller
{
public function store(Request $request, Room $room): Response
{
$player = $request->attributes->get('currentPlayer');
abort_unless(
$player instanceof Player && $player->room_id === $room->id,
403,
);
$validated = $request->validate([
'target_player_id' => ['required', 'integer'],
]);
$targetPlayerId = (int) $validated['target_player_id'];
if ($targetPlayerId === $player->id) {
throw ValidationException::withMessages([
'target_player_id' => 'Du kannst dich nicht selbst abwerfen.',
]);
}
$targetExists = $room->players()
->whereKey($targetPlayerId)
->exists();
if (! $targetExists) {
throw ValidationException::withMessages([
'target_player_id' => 'Dieser Spieler sitzt nicht an diesem Tisch.',
]);
}
$rateLimitKey = "room-emote:{$room->id}:{$player->id}";
abort_if(
RateLimiter::tooManyAttempts($rateLimitKey, 10),
429,
'Bitte warte kurz, bevor du erneut wirfst.',
);
RateLimiter::hit($rateLimitKey, 10);
PaperBallThrown::dispatch(
Str::uuid()->toString(),
$room->id,
$player->id,
$targetPlayerId,
);
return response()->noContent();
}
}
@@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Http\Requests\JoinRoomRequest;
use App\Models\Player;
use App\Models\Room;
@@ -54,6 +56,12 @@ class RoomPlayerController extends Controller
$player->id,
);
RoomStateChanged::dispatch(
$room->id,
$room->state_version,
RoomStateChange::PlayerJoined,
);
return to_route('room.show', $room);
}
+29 -3
View File
@@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Models\Player;
use App\Models\Room;
use App\Models\Vote;
@@ -25,7 +27,7 @@ class RoundController extends Controller
403,
);
DB::transaction(function () use ($room) {
$stateVersion = DB::transaction(function () use ($room): int {
$lockedRoom = Room::query()
->lockForUpdate()
->findOrFail($room->id);
@@ -42,8 +44,16 @@ class RoundController extends Controller
'last_activity_at' => now(),
'state_version' => $lockedRoom->state_version + 1,
]);
return $lockedRoom->state_version;
});
RoomStateChanged::dispatch(
$room->id,
$stateVersion,
RoomStateChange::VotesReset,
);
return back();
}
@@ -60,7 +70,7 @@ class RoundController extends Controller
403,
);
DB::transaction(function () use ($room) {
$stateVersion = DB::transaction(function () use ($room): int {
$lockedRoom = Room::query()
->lockForUpdate()
->findOrFail($room->id);
@@ -78,8 +88,16 @@ class RoundController extends Controller
'last_activity_at' => now(),
'state_version' => $lockedRoom->state_version + 1,
]);
return $lockedRoom->state_version;
});
RoomStateChanged::dispatch(
$room->id,
$stateVersion,
RoomStateChange::CardsRevealed,
);
return back();
}
@@ -96,7 +114,7 @@ class RoundController extends Controller
403,
);
DB::transaction(function () use ($room) {
$stateVersion = DB::transaction(function () use ($room): int {
$lockedRoom = Room::query()
->lockForUpdate()
->findOrFail($room->id);
@@ -115,8 +133,16 @@ class RoundController extends Controller
'last_activity_at' => now(),
'state_version' => $lockedRoom->state_version + 1,
]);
return $lockedRoom->state_version;
});
RoomStateChanged::dispatch(
$room->id,
$stateVersion,
RoomStateChange::NextRound,
);
return back();
}
+11 -1
View File
@@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Http\Requests\SubmitVoteRequest;
use App\Models\Player;
use App\Models\Room;
@@ -26,7 +28,7 @@ class VoteController extends Controller
$value = $request->validated('value');
DB::transaction(function () use ($room, $player, $value) {
$stateVersion = DB::transaction(function () use ($room, $player, $value): int {
$lockedRoom = Room::query()
->lockForUpdate()
->findOrFail($room->id);
@@ -58,8 +60,16 @@ class VoteController extends Controller
'last_activity_at' => now(),
'state_version' => $lockedRoom->state_version + 1,
]);
return $lockedRoom->state_version;
});
RoomStateChanged::dispatch(
$room->id,
$stateVersion,
RoomStateChange::VoteChanged,
);
return back();
}
}
+37 -20
View File
@@ -2,6 +2,8 @@
namespace App\Jobs;
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Models\Room;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
@@ -13,7 +15,7 @@ class CloseInactiveRooms implements ShouldQueue
public function handle(): void
{
$closedAt = now();
$closedAt = now()->toImmutable();
$guestCutoff = $closedAt->subDays(config('rooms.guest_retention_days'));
$accountCutoff = $closedAt->subDays(config('rooms.account_retention_days'));
@@ -32,25 +34,40 @@ class CloseInactiveRooms implements ShouldQueue
})
->select('id')
->chunkById(100, function ($rooms) use ($closedAt, $guestCutoff, $accountCutoff): void {
Room::query()
->whereKey($rooms->pluck('id'))
->whereNull('closed_at')
->where(function ($query) use ($guestCutoff, $accountCutoff): void {
$query
->where(function ($query) use ($guestCutoff): void {
$query->whereNull('owner_id')
->where('last_activity_at', '<=', $guestCutoff);
})
->orWhere(function ($query) use ($accountCutoff): void {
$query->whereNotNull('owner_id')
->where('last_activity_at', '<=', $accountCutoff);
});
})
->update([
'closed_at' => $closedAt,
'state_version' => DB::raw('state_version + 1'),
'updated_at' => $closedAt,
]);
foreach ($rooms->pluck('id') as $roomId) {
$stateVersion = DB::transaction(function () use ($roomId, $closedAt, $guestCutoff, $accountCutoff): ?int {
$room = Room::query()
->lockForUpdate()
->find((string) $roomId);
if ($room === null || $room->isClosed() || $room->last_activity_at === null) {
return null;
}
$cutoff = $room->owner_id === null
? $guestCutoff
: $accountCutoff;
if ($room->last_activity_at->greaterThan($cutoff)) {
return null;
}
$room->update([
'closed_at' => $closedAt,
'state_version' => $room->state_version + 1,
]);
return $room->state_version;
});
if ($stateVersion !== null) {
RoomStateChanged::dispatch(
(string) $roomId,
$stateVersion,
RoomStateChange::RoomClosed,
);
}
}
}, 'id');
}
}
+5
View File
@@ -3,6 +3,7 @@
namespace App\Providers;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\DevCommands;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
@@ -24,6 +25,10 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
$this->configureDefaults();
if (app()->isLocal()) {
DevCommands::artisan('reverb:start --debug', 'reverb');
}
}
/**
+1
View File
@@ -13,6 +13,7 @@ return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
+2 -1
View File
@@ -14,6 +14,7 @@
"laravel/chisel": "^0.1.0",
"laravel/fortify": "^1.37.2",
"laravel/framework": "^13.17",
"laravel/reverb": "^1.11",
"laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.14"
},
@@ -116,4 +117,4 @@
},
"minimum-stability": "stable",
"prefer-stable": true
}
}
Generated
+909 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "859863f5ca81e4a2e3cf62f862513073",
"content-hash": "7f5e9a72c66a95e3c6cd86619f4c2c0c",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -189,6 +189,136 @@
],
"time": "2024-02-09T16:56:22+00:00"
},
{
"name": "clue/redis-protocol",
"version": "v0.3.2",
"source": {
"type": "git",
"url": "https://github.com/clue/redis-protocol.git",
"reference": "6f565332f5531b7722d1e9c445314b91862f6d6c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/clue/redis-protocol/zipball/6f565332f5531b7722d1e9c445314b91862f6d6c",
"reference": "6f565332f5531b7722d1e9c445314b91862f6d6c",
"shasum": ""
},
"require": {
"php": ">=5.3"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"type": "library",
"autoload": {
"psr-4": {
"Clue\\Redis\\Protocol\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@lueck.tv"
}
],
"description": "A streaming Redis protocol (RESP) parser and serializer written in pure PHP.",
"homepage": "https://github.com/clue/redis-protocol",
"keywords": [
"parser",
"protocol",
"redis",
"resp",
"serializer",
"streaming"
],
"support": {
"issues": "https://github.com/clue/redis-protocol/issues",
"source": "https://github.com/clue/redis-protocol/tree/v0.3.2"
},
"funding": [
{
"url": "https://clue.engineering/support",
"type": "custom"
},
{
"url": "https://github.com/clue",
"type": "github"
}
],
"time": "2024-08-07T11:06:28+00:00"
},
{
"name": "clue/redis-react",
"version": "v2.8.0",
"source": {
"type": "git",
"url": "https://github.com/clue/reactphp-redis.git",
"reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/clue/reactphp-redis/zipball/84569198dfd5564977d2ae6a32de4beb5a24bdca",
"reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca",
"shasum": ""
},
"require": {
"clue/redis-protocol": "^0.3.2",
"evenement/evenement": "^3.0 || ^2.0 || ^1.0",
"php": ">=5.3",
"react/event-loop": "^1.2",
"react/promise": "^3.2 || ^2.0 || ^1.1",
"react/promise-timer": "^1.11",
"react/socket": "^1.16"
},
"require-dev": {
"clue/block-react": "^1.5",
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"type": "library",
"autoload": {
"psr-4": {
"Clue\\React\\Redis\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering"
}
],
"description": "Async Redis client implementation, built on top of ReactPHP.",
"homepage": "https://github.com/clue/reactphp-redis",
"keywords": [
"async",
"client",
"database",
"reactphp",
"redis"
],
"support": {
"issues": "https://github.com/clue/reactphp-redis/issues",
"source": "https://github.com/clue/reactphp-redis/tree/v2.8.0"
},
"funding": [
{
"url": "https://clue.engineering/support",
"type": "custom"
},
{
"url": "https://github.com/clue",
"type": "github"
}
],
"time": "2025-01-03T16:18:33+00:00"
},
{
"name": "dasprid/enum",
"version": "1.0.7",
@@ -660,6 +790,53 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "evenement/evenement",
"version": "v3.0.2",
"source": {
"type": "git",
"url": "https://github.com/igorw/evenement.git",
"reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc",
"reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc",
"shasum": ""
},
"require": {
"php": ">=7.0"
},
"require-dev": {
"phpunit/phpunit": "^9 || ^6"
},
"type": "library",
"autoload": {
"psr-4": {
"Evenement\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Igor Wiedler",
"email": "igor@wiedler.ch"
}
],
"description": "Événement is a very simple event dispatching library for PHP",
"keywords": [
"event-dispatcher",
"event-emitter"
],
"support": {
"issues": "https://github.com/igorw/evenement/issues",
"source": "https://github.com/igorw/evenement/tree/v3.0.2"
},
"time": "2023-08-08T05:53:35+00:00"
},
{
"name": "fruitcake/php-cors",
"version": "v1.4.0",
@@ -1752,6 +1929,85 @@
},
"time": "2026-08-04T14:50:50+00:00"
},
{
"name": "laravel/reverb",
"version": "v1.11.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/reverb.git",
"reference": "52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/reverb/zipball/52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27",
"reference": "52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27",
"shasum": ""
},
"require": {
"clue/redis-react": "^2.6",
"guzzlehttp/psr7": "^2.6",
"illuminate/console": "^10.47|^11.0|^12.0|^13.0",
"illuminate/contracts": "^10.47|^11.0|^12.0|^13.0",
"illuminate/http": "^10.47|^11.0|^12.0|^13.0",
"illuminate/support": "^10.47|^11.0|^12.0|^13.0",
"laravel/prompts": "^0.1.15|^0.2.0|^0.3.0",
"php": "^8.2",
"pusher/pusher-php-server": "^7.2",
"ratchet/rfc6455": "^0.4",
"react/promise-timer": "^1.10",
"react/socket": "^1.14",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/http-foundation": "^6.3|^7.0|^8.0"
},
"require-dev": {
"orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
"pestphp/pest": "^2.0|^3.0|^4.0",
"phpstan/phpstan": "^1.10",
"ratchet/pawl": "^0.4.1",
"react/async": "^4.2",
"react/http": "^1.9"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Reverb\\ApplicationManagerServiceProvider",
"Laravel\\Reverb\\ReverbServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Reverb\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Joe Dixon",
"email": "joe@laravel.com"
}
],
"description": "Laravel Reverb provides a real-time WebSocket communication backend for Laravel applications.",
"keywords": [
"WebSockets",
"laravel",
"real-time",
"websocket"
],
"support": {
"issues": "https://github.com/laravel/reverb/issues",
"source": "https://github.com/laravel/reverb/tree/v1.11.1"
},
"time": "2026-08-06T14:48:58+00:00"
},
{
"name": "laravel/serializable-closure",
"version": "v2.0.15",
@@ -3927,6 +4183,69 @@
},
"time": "2026-06-29T15:41:09+00:00"
},
{
"name": "pusher/pusher-php-server",
"version": "7.3.0",
"source": {
"type": "git",
"url": "https://github.com/pusher/pusher-http-php.git",
"reference": "058d8464246118110a341fc2e6e70c7c8b6a7f2c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/058d8464246118110a341fc2e6e70c7c8b6a7f2c",
"reference": "058d8464246118110a341fc2e6e70c7c8b6a7f2c",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"guzzlehttp/guzzle": "^7.8.2 || ^8.0",
"guzzlehttp/promises": "^2.0.3 || ^3.0",
"guzzlehttp/psr7": "^2.6.3 || ^3.0",
"php": "^7.3|^8.0",
"psr/http-client": "^1.0",
"psr/log": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"overtrue/phplint": "^2.3",
"phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "5.0-dev"
}
},
"autoload": {
"psr-4": {
"Pusher\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Library for interacting with the Pusher REST API",
"keywords": [
"events",
"messaging",
"php-pusher-server",
"publish",
"push",
"pusher",
"real time",
"real-time",
"realtime",
"rest",
"trigger"
],
"support": {
"issues": "https://github.com/pusher/pusher-http-php/issues",
"source": "https://github.com/pusher/pusher-http-php/tree/7.3.0"
},
"time": "2026-08-07T12:47:11+00:00"
},
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",
@@ -4125,6 +4444,595 @@
},
"time": "2026-06-18T03:57:49+00:00"
},
{
"name": "ratchet/rfc6455",
"version": "v0.4.1",
"source": {
"type": "git",
"url": "https://github.com/ratchetphp/RFC6455.git",
"reference": "9b05f371219cbaf9748b505f139617dd0715592b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/9b05f371219cbaf9748b505f139617dd0715592b",
"reference": "9b05f371219cbaf9748b505f139617dd0715592b",
"shasum": ""
},
"require": {
"php": ">=7.4",
"psr/http-factory-implementation": "^1.0",
"symfony/polyfill-php80": "^1.15"
},
"require-dev": {
"guzzlehttp/psr7": "^2.7",
"phpunit/phpunit": "^9.5",
"react/socket": "^1.3"
},
"type": "library",
"autoload": {
"psr-4": {
"Ratchet\\RFC6455\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"role": "Developer"
},
{
"name": "Matt Bonneau",
"role": "Developer"
}
],
"description": "RFC6455 WebSocket protocol handler",
"homepage": "http://socketo.me",
"keywords": [
"WebSockets",
"rfc6455",
"websocket"
],
"support": {
"chat": "https://gitter.im/reactphp/reactphp",
"issues": "https://github.com/ratchetphp/RFC6455/issues",
"source": "https://github.com/ratchetphp/RFC6455/tree/v0.4.1"
},
"time": "2026-06-06T14:34:23+00:00"
},
{
"name": "react/cache",
"version": "v1.2.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/cache.git",
"reference": "d47c472b64aa5608225f47965a484b75c7817d5b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b",
"reference": "d47c472b64aa5608225f47965a484b75c7817d5b",
"shasum": ""
},
"require": {
"php": ">=5.3.0",
"react/promise": "^3.0 || ^2.0 || ^1.1"
},
"require-dev": {
"phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35"
},
"type": "library",
"autoload": {
"psr-4": {
"React\\Cache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "Async, Promise-based cache interface for ReactPHP",
"keywords": [
"cache",
"caching",
"promise",
"reactphp"
],
"support": {
"issues": "https://github.com/reactphp/cache/issues",
"source": "https://github.com/reactphp/cache/tree/v1.2.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2022-11-30T15:59:55+00:00"
},
{
"name": "react/dns",
"version": "v1.14.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/dns.git",
"reference": "7562c05391f42701c1fccf189c8225fece1cd7c3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3",
"reference": "7562c05391f42701c1fccf189c8225fece1cd7c3",
"shasum": ""
},
"require": {
"php": ">=5.3.0",
"react/cache": "^1.0 || ^0.6 || ^0.5",
"react/event-loop": "^1.2",
"react/promise": "^3.2 || ^2.7 || ^1.2.1"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
"react/async": "^4.3 || ^3 || ^2",
"react/promise-timer": "^1.11"
},
"type": "library",
"autoload": {
"psr-4": {
"React\\Dns\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "Async DNS resolver for ReactPHP",
"keywords": [
"async",
"dns",
"dns-resolver",
"reactphp"
],
"support": {
"issues": "https://github.com/reactphp/dns/issues",
"source": "https://github.com/reactphp/dns/tree/v1.14.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2025-11-18T19:34:28+00:00"
},
{
"name": "react/event-loop",
"version": "v1.6.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/event-loop.git",
"reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
"reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
"shasum": ""
},
"require": {
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"suggest": {
"ext-pcntl": "For signal handling support when using the StreamSelectLoop"
},
"type": "library",
"autoload": {
"psr-4": {
"React\\EventLoop\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.",
"keywords": [
"asynchronous",
"event-loop"
],
"support": {
"issues": "https://github.com/reactphp/event-loop/issues",
"source": "https://github.com/reactphp/event-loop/tree/v1.6.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2025-11-17T20:46:25+00:00"
},
{
"name": "react/promise",
"version": "v3.3.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/promise.git",
"reference": "23444f53a813a3296c1368bb104793ce8d88f04a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a",
"reference": "23444f53a813a3296c1368bb104793ce8d88f04a",
"shasum": ""
},
"require": {
"php": ">=7.1.0"
},
"require-dev": {
"phpstan/phpstan": "1.12.28 || 1.4.10",
"phpunit/phpunit": "^9.6 || ^7.5"
},
"type": "library",
"autoload": {
"files": [
"src/functions_include.php"
],
"psr-4": {
"React\\Promise\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "A lightweight implementation of CommonJS Promises/A for PHP",
"keywords": [
"promise",
"promises"
],
"support": {
"issues": "https://github.com/reactphp/promise/issues",
"source": "https://github.com/reactphp/promise/tree/v3.3.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2025-08-19T18:57:03+00:00"
},
{
"name": "react/promise-timer",
"version": "v1.11.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/promise-timer.git",
"reference": "4f70306ed66b8b44768941ca7f142092600fafc1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/promise-timer/zipball/4f70306ed66b8b44768941ca7f142092600fafc1",
"reference": "4f70306ed66b8b44768941ca7f142092600fafc1",
"shasum": ""
},
"require": {
"php": ">=5.3",
"react/event-loop": "^1.2",
"react/promise": "^3.2 || ^2.7.0 || ^1.2.1"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"type": "library",
"autoload": {
"files": [
"src/functions_include.php"
],
"psr-4": {
"React\\Promise\\Timer\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "A trivial implementation of timeouts for Promises, built on top of ReactPHP.",
"homepage": "https://github.com/reactphp/promise-timer",
"keywords": [
"async",
"event-loop",
"promise",
"reactphp",
"timeout",
"timer"
],
"support": {
"issues": "https://github.com/reactphp/promise-timer/issues",
"source": "https://github.com/reactphp/promise-timer/tree/v1.11.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2024-06-04T14:27:45+00:00"
},
{
"name": "react/socket",
"version": "v1.17.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/socket.git",
"reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08",
"reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08",
"shasum": ""
},
"require": {
"evenement/evenement": "^3.0 || ^2.0 || ^1.0",
"php": ">=5.3.0",
"react/dns": "^1.13",
"react/event-loop": "^1.2",
"react/promise": "^3.2 || ^2.6 || ^1.2.1",
"react/stream": "^1.4"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
"react/async": "^4.3 || ^3.3 || ^2",
"react/promise-stream": "^1.4",
"react/promise-timer": "^1.11"
},
"type": "library",
"autoload": {
"psr-4": {
"React\\Socket\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP",
"keywords": [
"Connection",
"Socket",
"async",
"reactphp",
"stream"
],
"support": {
"issues": "https://github.com/reactphp/socket/issues",
"source": "https://github.com/reactphp/socket/tree/v1.17.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2025-11-19T20:47:34+00:00"
},
{
"name": "react/stream",
"version": "v1.4.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/stream.git",
"reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d",
"reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d",
"shasum": ""
},
"require": {
"evenement/evenement": "^3.0 || ^2.0 || ^1.0",
"php": ">=5.3.8",
"react/event-loop": "^1.2"
},
"require-dev": {
"clue/stream-filter": "~1.2",
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"type": "library",
"autoload": {
"psr-4": {
"React\\Stream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP",
"keywords": [
"event-driven",
"io",
"non-blocking",
"pipe",
"reactphp",
"readable",
"stream",
"writable"
],
"support": {
"issues": "https://github.com/reactphp/stream/issues",
"source": "https://github.com/reactphp/stream/tree/v1.4.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2024-06-11T12:45:25+00:00"
},
{
"name": "spomky-labs/cbor-php",
"version": "3.3.0",
+34
View File
@@ -0,0 +1,34 @@
<?php
return [
'default' => env('BROADCAST_CONNECTION', 'null'),
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
'path' => env('REVERB_SERVER_PATH', ''),
],
'client_options' => [],
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];
+74
View File
@@ -0,0 +1,74 @@
<?php
$allowedOrigins = array_values(array_filter(array_map(
'trim',
explode(',', (string) env('REVERB_ALLOWED_ORIGINS', '*')),
)));
return [
'default' => env('REVERB_SERVER', 'reverb'),
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'path' => env('REVERB_SERVER_PATH', ''),
'hostname' => env('REVERB_HOST'),
'options' => [
'tls' => [],
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
'server' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', '6379'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'database' => env('REDIS_DB', '0'),
'timeout' => env('REDIS_TIMEOUT', 60),
],
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => $allowedOrigins,
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
'accept_client_events_from' => 'none',
'rate_limiting' => [
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
],
],
],
],
];
+2
View File
@@ -41,7 +41,9 @@
"@vueuse/core": "^12.8.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"laravel-echo": "^2.4.0",
"laravel-vite-plugin": "^3.0.0",
"pusher-js": "^8.6.0",
"reka-ui": "^2.9.8",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.1",
+34
View File
@@ -29,9 +29,15 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
laravel-echo:
specifier: ^2.4.0
version: 2.4.0(pusher-js@8.6.0)
laravel-vite-plugin:
specifier: ^3.0.0
version: 3.2.0(vite@8.2.1(@types/node@22.20.1)(jiti@2.7.0))
pusher-js:
specifier: ^8.6.0
version: 8.6.0
reka-ui:
specifier: ^2.9.8
version: 2.10.3(vue@3.5.41(typescript@5.9.3))
@@ -1610,6 +1616,18 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
laravel-echo@2.4.0:
resolution: {integrity: sha512-8w0fAGSNt6THfbNyqdKc29bhfeNpJg13CGx2fcLgoX0/f0mTJm/AIkYTTakmcr9pc42ZB68cSoE00j4/xNaFGQ==}
engines: {node: '>=20'}
peerDependencies:
pusher-js: '*'
socket.io-client: '*'
peerDependenciesMeta:
pusher-js:
optional: true
socket.io-client:
optional: true
laravel-precognition@2.0.0:
resolution: {integrity: sha512-dmA4HGc9m+TsVNsJs9/XQBI8u6j7coilN+qKkBuhuXQzH3HypwS/c5dFQ4UqUGjBbcxIM7zdk91kM/SRZwIvWQ==}
peerDependencies:
@@ -2020,6 +2038,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
pusher-js@8.6.0:
resolution: {integrity: sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2253,6 +2274,9 @@ packages:
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
tweetnacl@1.0.3:
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -4072,6 +4096,10 @@ snapshots:
dependencies:
json-buffer: 3.0.1
laravel-echo@2.4.0(pusher-js@8.6.0):
optionalDependencies:
pusher-js: 8.6.0
laravel-precognition@2.0.0:
dependencies:
es-toolkit: 1.50.0
@@ -4360,6 +4388,10 @@ snapshots:
punycode@2.3.1: {}
pusher-js@8.6.0:
dependencies:
tweetnacl: 1.0.3
queue-microtask@1.2.3: {}
react-reconciler@0.33.0(react@19.2.8):
@@ -4653,6 +4685,8 @@ snapshots:
tw-animate-css@1.4.0: {}
tweetnacl@1.0.3: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -0,0 +1,194 @@
import { nextTick, onScopeDispose, ref } from 'vue';
import { toast } from 'vue-sonner';
import { useRoomEmotes } from '@/composables/useRoomEmotes';
import { store as throwPaperBallRoute } from '@/routes/room/emote/paper-ball';
import type {
PaperBallFlight,
PaperBallThrowError,
PaperBallThrownPayload,
PaperBallTossOptions,
} from '@/types/emote';
const PAPER_BALL_FLIGHT_DURATION = 780;
const SEEN_EVENT_RETENTION = 5_000;
const THROW_BUTTON_COOLDOWN = 550;
export function usePaperBallToss({
roomId,
currentPlayerId,
board,
}: PaperBallTossOptions) {
const activePaperBalls = ref<PaperBallFlight[]>([]);
const hitPlayerIds = ref<number[]>([]);
const throwingAtPlayerId = ref<number | null>(null);
const seenPaperBallIds = new Set<string>();
const timers = new Set<number>();
function schedule(callback: () => void, delay: number): void {
const timer = window.setTimeout(() => {
timers.delete(timer);
callback();
}, delay);
timers.add(timer);
}
async function throwPaperBall(targetPlayerId: number): Promise<void> {
if (
targetPlayerId === currentPlayerId() ||
throwingAtPlayerId.value !== null
) {
return;
}
const csrfToken = document
.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')
?.getAttribute('content');
if (!csrfToken) {
toast.error('Der Papierknäuel konnte nicht geworfen werden.');
return;
}
throwingAtPlayerId.value = targetPlayerId;
try {
const response = await fetch(throwPaperBallRoute(roomId()).url, {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
target_player_id: targetPlayerId,
}),
});
if (response.redirected) {
toast.error(
'Bitte lade den Raum neu und versuche es noch einmal.',
);
return;
}
if (!response.ok) {
const error = (await response
.json()
.catch(() => null)) as PaperBallThrowError | null;
const message =
error?.errors?.target_player_id?.[0] ??
error?.message ??
'Der Papierknäuel konnte nicht geworfen werden.';
toast.error(message);
}
} catch {
toast.error('Der Papierknäuel konnte nicht geworfen werden.');
} finally {
schedule(() => {
if (throwingAtPlayerId.value === targetPlayerId) {
throwingAtPlayerId.value = null;
}
}, THROW_BUTTON_COOLDOWN);
}
}
async function animatePaperBall(
payload: PaperBallThrownPayload,
): Promise<void> {
if (
typeof payload.id !== 'string' ||
!Number.isInteger(payload.from_player_id) ||
!Number.isInteger(payload.target_player_id) ||
seenPaperBallIds.has(payload.id)
) {
return;
}
seenPaperBallIds.add(payload.id);
schedule(
() => seenPaperBallIds.delete(payload.id),
SEEN_EVENT_RETENTION,
);
await nextTick();
const gameBoard = board.value;
const fromAvatar = gameBoard?.querySelector<HTMLElement>(
`[data-player-avatar="${payload.from_player_id}"]`,
);
const targetAvatar = gameBoard?.querySelector<HTMLElement>(
`[data-player-avatar="${payload.target_player_id}"]`,
);
if (!gameBoard || !fromAvatar || !targetAvatar) {
return;
}
const boardRect = gameBoard.getBoundingClientRect();
const fromRect = fromAvatar.getBoundingClientRect();
const targetRect = targetAvatar.getBoundingClientRect();
const startX = fromRect.left + fromRect.width / 2 - boardRect.left;
const startY = fromRect.top + fromRect.height / 2 - boardRect.top;
const travelX =
targetRect.left + targetRect.width / 2 - boardRect.left - startX;
const travelY =
targetRect.top + targetRect.height / 2 - boardRect.top - startY;
const distance = Math.hypot(travelX, travelY);
const arcHeight = Math.min(130, Math.max(55, distance * 0.22));
activePaperBalls.value.push({
id: payload.id,
style: {
left: `${startX}px`,
top: `${startY}px`,
'--travel-quarter-x': `${travelX * 0.25}px`,
'--travel-quarter-y': `${travelY * 0.25 - arcHeight * 0.75}px`,
'--travel-mid-x': `${travelX * 0.5}px`,
'--travel-mid-y': `${travelY * 0.5 - arcHeight}px`,
'--travel-three-quarter-x': `${travelX * 0.75}px`,
'--travel-three-quarter-y': `${travelY * 0.75 - arcHeight * 0.75}px`,
'--travel-x': `${travelX}px`,
'--travel-y': `${travelY}px`,
},
});
schedule(() => {
if (!hitPlayerIds.value.includes(payload.target_player_id)) {
hitPlayerIds.value.push(payload.target_player_id);
}
}, PAPER_BALL_FLIGHT_DURATION - 80);
schedule(() => {
activePaperBalls.value = activePaperBalls.value.filter(
(paperBall) => paperBall.id !== payload.id,
);
}, PAPER_BALL_FLIGHT_DURATION + 40);
schedule(() => {
hitPlayerIds.value = hitPlayerIds.value.filter(
(playerId) => playerId !== payload.target_player_id,
);
}, PAPER_BALL_FLIGHT_DURATION + 420);
}
useRoomEmotes(roomId, animatePaperBall);
onScopeDispose(() => {
timers.forEach((timer) => window.clearTimeout(timer));
timers.clear();
});
return {
activePaperBalls,
hitPlayerIds,
throwingAtPlayerId,
throwPaperBall,
};
}
+27
View File
@@ -0,0 +1,27 @@
import { onScopeDispose, watch } from 'vue';
import { getEcho } from '@/lib/echo';
import type { PaperBallThrownPayload } from '@/types/emote';
const PAPER_BALL_EVENT = '.room.emote.thrown';
export function useRoomEmotes(
roomId: () => string,
onPaperBallThrown: (payload: PaperBallThrownPayload) => void,
): void {
const stopWatchingRoom = watch(
roomId,
(nextRoomId, _previousRoomId, onCleanup) => {
const channel = getEcho().channel(`rooms.${nextRoomId}`);
channel.listen(PAPER_BALL_EVENT, onPaperBallThrown);
onCleanup(() => {
channel.stopListening(PAPER_BALL_EVENT, onPaperBallThrown);
});
},
{ immediate: true },
);
onScopeDispose(stopWatchingRoom);
}
@@ -0,0 +1,94 @@
import { router } from '@inertiajs/vue3';
import { onScopeDispose, watch } from 'vue';
import { getEcho } from '@/lib/echo';
import type { RoomStateChangedPayload } from '@/types/room';
const ROOM_EVENT = '.room.state.changed';
export function useRoomRealtime(
roomId: () => string,
stateVersion: () => number,
): void {
let reloadInProgress = false;
let pendingVersion = 0;
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 {
if (
typeof payload.version !== 'number' ||
payload.version <= Math.max(stateVersion(), pendingVersion)
) {
return;
}
pendingVersion = payload.version;
if (payload.change === 'room_deleted') {
router.visit('/', { replace: true });
return;
}
reloadRoom();
}
const stopWatchingRoom = watch(
roomId,
(nextRoomId, _previousRoomId, onCleanup) => {
const echo = getEcho();
const channelName = `rooms.${nextRoomId}`;
pendingVersion = stateVersion();
echo.channel(channelName).listen(ROOM_EVENT, handleRoomChange);
onCleanup(() => {
echo.leaveChannel(channelName);
});
},
{ immediate: true },
);
const stopWatchingVersion = watch(
stateVersion,
(version) => {
if (!reloadInProgress) {
pendingVersion = Math.max(pendingVersion, version);
}
},
{ flush: 'sync' },
);
onScopeDispose(() => {
disposed = true;
stopWatchingRoom();
stopWatchingVersion();
});
}
+33
View File
@@ -0,0 +1,33 @@
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
let echoClient: Echo<'reverb'> | null = null;
function environmentString(key: string): string | undefined {
const value = import.meta.env[key];
return typeof value === 'string' && value !== '' ? value : undefined;
}
export function getEcho(): Echo<'reverb'> {
if (echoClient !== null) {
return echoClient;
}
const scheme = environmentString('VITE_REVERB_SCHEME') ?? 'http';
const forceTLS = scheme === 'https';
echoClient = new Echo<'reverb'>({
broadcaster: 'reverb',
Pusher,
key: environmentString('VITE_REVERB_APP_KEY') ?? 'local-key',
wsHost:
environmentString('VITE_REVERB_HOST') ?? window.location.hostname,
wsPort: Number(environmentString('VITE_REVERB_PORT') ?? 8080),
wssPort: Number(environmentString('VITE_REVERB_PORT') ?? 443),
forceTLS,
enabledTransports: ['ws', 'wss'],
});
return echoClient;
}
+208 -2
View File
@@ -12,6 +12,8 @@ import {
} from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { usePaperBallToss } from '@/composables/usePaperBallToss';
import { useRoomRealtime } from '@/composables/useRoomRealtime';
import { login, register } from '@/routes';
import { nextRound, reset as resetRound, reveal } from '@/routes/room';
import { store as storeVote } from '@/routes/room/vote';
@@ -60,8 +62,20 @@ const nextRoundForm = useForm<{ room?: string }>({});
const resetForm = useForm<{ room?: string }>({});
const copied = ref(false);
const roomNoticeDismissed = ref(false);
const gameBoard = ref<HTMLElement | null>(null);
const { activePaperBalls, hitPlayerIds, throwingAtPlayerId, throwPaperBall } =
usePaperBallToss({
roomId: () => props.room.id,
currentPlayerId: () => props.currentPlayer.id,
board: gameBoard,
});
usePoll(2_000, {
useRoomRealtime(
() => props.room.id,
() => props.room.state_version,
);
usePoll(30_000, {
only: ['room', 'currentPlayer'],
});
@@ -252,7 +266,11 @@ function playerVote(player: RoomPlayer): string | null {
</header>
<section class="mt-5 w-full">
<div class="game-board" aria-label="Scrum-Poker-Tisch">
<div
ref="gameBoard"
class="game-board"
aria-label="Scrum-Poker-Tisch"
>
<div class="poker-table" aria-hidden="true">
<div class="table-highlight"></div>
<div class="table-center">
@@ -291,11 +309,25 @@ function playerVote(player: RoomPlayer): string | null {
</div>
</div>
<div
v-for="paperBall in activePaperBalls"
:key="paperBall.id"
class="paper-ball-flight"
:style="paperBall.style"
aria-hidden="true"
>
<span class="paper-ball"></span>
</div>
<article
v-for="player in positionedPlayers"
:key="player.id"
class="player-seat"
:class="{
'is-paper-ball-hit': hitPlayerIds.includes(player.id),
}"
:style="player.seatStyle"
:data-player-id="player.id"
>
<div
v-if="player.has_voted"
@@ -328,6 +360,7 @@ function playerVote(player: RoomPlayer): string | null {
<div
class="mx-auto grid size-12 place-items-center rounded-full border border-slate-600 bg-slate-800 text-slate-300 sm:size-14"
:data-player-avatar="player.id"
>
<CircleUserRound
class="size-7 sm:size-8"
@@ -374,6 +407,21 @@ function playerVote(player: RoomPlayer): string | null {
</span>
</div>
</div>
<button
v-if="!isCurrentPlayer(player)"
type="button"
class="throw-paper-ball"
:class="{
'is-throwing': throwingAtPlayerId === player.id,
}"
:disabled="throwingAtPlayerId !== null"
:aria-label="`Papierknäuel auf ${player.name} werfen`"
:title="`Papierknäuel auf ${player.name} werfen`"
@click.stop="throwPaperBall(player.id)"
>
<span class="paper-ball-mini" aria-hidden="true"></span>
</button>
</article>
<div v-if="currentPlayer.is_moderator" class="reset-controls">
@@ -528,6 +576,149 @@ function playerVote(player: RoomPlayer): string | null {
overflow-wrap: anywhere;
}
.throw-paper-ball {
position: absolute;
top: 0.65rem;
right: 0.35rem;
z-index: 8;
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border: 1px solid #64748b;
border-radius: 0.5rem;
background: #334155;
opacity: 0;
transform: translateY(0.25rem) rotate(-8deg);
pointer-events: none;
transition:
opacity 140ms ease,
transform 140ms ease,
background-color 140ms ease;
}
.player-seat:hover .throw-paper-ball,
.player-seat:focus-within .throw-paper-ball {
opacity: 1;
transform: translateY(0) rotate(-8deg);
pointer-events: auto;
}
.throw-paper-ball:hover:not(:disabled),
.throw-paper-ball:focus-visible {
background: #475569;
transform: translateY(-0.1rem) rotate(4deg);
}
.throw-paper-ball:focus-visible {
outline: 2px solid #67e8f9;
outline-offset: 2px;
}
.throw-paper-ball:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.throw-paper-ball.is-throwing {
opacity: 1;
}
.paper-ball-mini,
.paper-ball {
display: block;
border: 1px solid rgb(100 116 139 / 65%);
border-radius: 48% 42% 54% 39%;
background:
linear-gradient(
35deg,
transparent 44%,
rgb(100 116 139 / 35%) 46% 51%,
transparent 53%
),
linear-gradient(
120deg,
transparent 35%,
rgb(148 163 184 / 45%) 37% 42%,
transparent 44%
),
#e2e8f0;
}
.paper-ball-mini {
width: 0.95rem;
height: 0.95rem;
transform: rotate(18deg);
}
.paper-ball-flight {
position: absolute;
z-index: 40;
width: 1.65rem;
height: 1.65rem;
pointer-events: none;
will-change: transform;
animation: paper-ball-flight 780ms linear forwards;
}
.paper-ball {
width: 100%;
height: 100%;
}
.player-seat.is-paper-ball-hit [data-player-avatar] {
animation: paper-ball-hit 380ms ease-out;
}
@keyframes paper-ball-flight {
0% {
transform: translate(-50%, -50%) translate(0, 0) rotate(0deg)
scale(0.72);
}
25% {
transform: translate(-50%, -50%)
translate(var(--travel-quarter-x), var(--travel-quarter-y))
rotate(195deg) scale(0.92);
}
50% {
transform: translate(-50%, -50%)
translate(var(--travel-mid-x), var(--travel-mid-y)) rotate(390deg)
scale(1.08);
}
75% {
transform: translate(-50%, -50%)
translate(
var(--travel-three-quarter-x),
var(--travel-three-quarter-y)
)
rotate(585deg) scale(1);
}
100% {
transform: translate(-50%, -50%)
translate(var(--travel-x), var(--travel-y)) rotate(780deg)
scale(0.82);
}
}
@keyframes paper-ball-hit {
0%,
100% {
transform: translateX(0) rotate(0deg);
}
28% {
transform: translateX(-0.35rem) rotate(-7deg);
}
62% {
transform: translateX(0.25rem) rotate(5deg);
}
}
.player-details-above {
transform: translateY(calc(-100% - 4.5rem));
}
@@ -715,10 +906,25 @@ function playerVote(player: RoomPlayer): string | null {
}
}
@media (hover: none) {
.throw-paper-ball {
opacity: 0.85;
pointer-events: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.flip-card-inner,
.deck-card {
transition: none;
}
.paper-ball-flight {
animation-duration: 1ms;
}
.player-seat.is-paper-ball-hit [data-player-avatar] {
animation: none;
}
}
</style>
+34
View File
@@ -0,0 +1,34 @@
import type { Ref, CSSProperties } from 'vue';
export type PaperBallThrownPayload = {
id: string;
from_player_id: number;
target_player_id: number;
};
export type PaperBallFlightStyle = CSSProperties & {
'--travel-quarter-x': string;
'--travel-quarter-y': string;
'--travel-mid-x': string;
'--travel-mid-y': string;
'--travel-three-quarter-x': string;
'--travel-three-quarter-y': string;
'--travel-x': string;
'--travel-y': string;
};
export type PaperBallFlight = {
id: string;
style: PaperBallFlightStyle;
};
export type PaperBallTossOptions = {
roomId: () => string;
currentPlayerId: () => number;
board: Ref<HTMLElement | null>;
};
export type PaperBallThrowError = {
message?: string;
errors?: Record<string, string[]>;
};
+15
View File
@@ -13,6 +13,21 @@ export type RoomPlayer = {
vote: string | null;
};
export type RoomStateChange =
| 'player_joined'
| 'vote_changed'
| 'cards_revealed'
| 'votes_reset'
| 'next_round'
| 'room_reopened'
| 'room_closed'
| 'room_deleted';
export type RoomStateChangedPayload = {
version: number;
change: RoomStateChange;
};
export type SeatStyle = CSSProperties & {
'--card-x': string;
'--card-y': string;
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
<script>
+3
View File
@@ -0,0 +1,3 @@
<?php
// Room updates use public UUID channels and do not need authorization callbacks.
+3
View File
@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\RoomController;
use App\Http\Controllers\RoomEmoteController;
use App\Http\Controllers\RoomPlayerController;
use App\Http\Controllers\RoundController;
use App\Http\Controllers\VoteController;
@@ -28,6 +29,8 @@ Route::middleware('room.player')->group(function () {
Route::get('/rooms/{room}', [RoomController::class, 'show'])->name('room.show');
Route::post('/rooms/{room}/votes', [VoteController::class, 'store'])
->name('room.vote.store');
Route::post('/rooms/{room}/emotes/paper-ball', [RoomEmoteController::class, 'store'])
->name('room.emote.paper-ball.store');
Route::post('/rooms/{room}/reveal', [RoundController::class, 'reveal'])
->name('room.reveal');
Route::post('/rooms/{room}/reset', [RoundController::class, 'reset'])
+174
View File
@@ -0,0 +1,174 @@
<?php
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Jobs\CloseInactiveRooms;
use App\Models\Room;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
function createBroadcastRoom(array $attributes = []): Room
{
return Room::create([
'title' => 'Realtime Planning',
'deck_config' => [
'name' => 'Fibonacci',
'cards' => [
['key' => '3', 'label' => '3'],
['key' => '5', 'label' => '5'],
],
],
...$attributes,
]);
}
test('room state broadcasts expose only invalidation metadata', function () {
$event = new RoomStateChanged(
'019b8f80-37c4-71a3-b415-5f2697332c16',
7,
RoomStateChange::VoteChanged,
);
expect($event->broadcastAs())->toBe('room.state.changed')
->and($event->broadcastOn()->name)->toBe('rooms.019b8f80-37c4-71a3-b415-5f2697332c16')
->and($event->broadcastWith())->toBe([
'version' => 7,
'change' => 'vote_changed',
])
->and($event->broadcastWith())->not->toHaveKeys([
'player_name',
'vote',
]);
});
test('joining a room broadcasts the new room version', function () {
Event::fake([RoomStateChanged::class]);
$room = createBroadcastRoom();
$this->post(route('room.code'), [
'join_code' => $room->join_code,
'player_name' => 'Anna',
])->assertRedirect(route('room.show', $room));
Event::assertDispatched(RoomStateChanged::class, fn (RoomStateChanged $event): bool => $event->roomId === $room->id
&& $event->version === $room->fresh()->state_version
&& $event->change === RoomStateChange::PlayerJoined
);
});
test('voting and round actions broadcast their state changes', function () {
Event::fake([RoomStateChanged::class]);
$room = createBroadcastRoom();
$moderator = $room->players()->create([
'name' => 'Eric',
'is_moderator' => true,
]);
$session = ["room_players.{$room->id}" => $moderator->id];
$this->withSession($session)
->post(route('room.vote.store', $room), ['value' => '5'])
->assertRedirect();
$this->withSession($session)
->post(route('room.reveal', $room))
->assertRedirect();
$this->withSession($session)
->post(route('room.next-round', $room))
->assertRedirect();
$this->withSession($session)
->post(route('room.vote.store', $room), ['value' => '3'])
->assertRedirect();
$this->withSession($session)
->post(route('room.reset', $room))
->assertRedirect();
foreach ([
RoomStateChange::VoteChanged,
RoomStateChange::CardsRevealed,
RoomStateChange::NextRound,
RoomStateChange::VotesReset,
] as $change) {
Event::assertDispatched(
RoomStateChanged::class,
fn (RoomStateChanged $event): bool => $event->change === $change,
);
}
Event::assertDispatchedTimes(RoomStateChanged::class, 5);
});
test('failed room actions do not broadcast updates', function () {
Event::fake([RoomStateChanged::class]);
$room = createBroadcastRoom();
$player = $room->players()->create([
'name' => 'Anna',
'is_moderator' => false,
]);
$this->withSession(["room_players.{$room->id}" => $player->id])
->post(route('room.reveal', $room))
->assertForbidden();
$this->withSession(["room_players.{$room->id}" => $player->id])
->post(route('room.vote.store', $room), ['value' => '100'])
->assertSessionHasErrors('value');
Event::assertNotDispatched(RoomStateChanged::class);
});
test('reopening and deleting a room broadcast lifecycle changes', function () {
Event::fake([RoomStateChanged::class]);
$owner = User::factory()->create();
$room = createBroadcastRoom([
'owner_id' => $owner->id,
'closed_at' => now(),
]);
$room->players()->create([
'user_id' => $owner->id,
'name' => 'Eric',
'is_moderator' => true,
]);
$this->actingAs($owner)
->post(route('room.reopen', $room))
->assertRedirect(route('room.show', $room));
$this->actingAs($owner)
->delete(route('room.destroy', $room))
->assertRedirect(route('dashboard'));
Event::assertDispatched(
RoomStateChanged::class,
fn (RoomStateChanged $event): bool => $event->change === RoomStateChange::RoomReopened,
);
Event::assertDispatched(
RoomStateChanged::class,
fn (RoomStateChanged $event): bool => $event->change === RoomStateChange::RoomDeleted,
);
Event::assertDispatchedTimes(RoomStateChanged::class, 2);
});
test('the cleanup job broadcasts only rooms it actually closes', function () {
Carbon::setTestNow('2026-08-16 12:00:00');
Event::fake([RoomStateChanged::class]);
$expiredRoom = createBroadcastRoom([
'last_activity_at' => now()->subDays(14),
]);
createBroadcastRoom([
'last_activity_at' => now()->subDays(14)->addSecond(),
]);
$job = new CloseInactiveRooms;
$job->handle();
$job->handle();
Event::assertDispatchedTimes(RoomStateChanged::class, 1);
Event::assertDispatched(RoomStateChanged::class, fn (RoomStateChanged $event): bool => $event->roomId === $expiredRoom->id
&& $event->version === 2
&& $event->change === RoomStateChange::RoomClosed
);
});
+101
View File
@@ -0,0 +1,101 @@
<?php
use App\Events\PaperBallThrown;
use App\Models\Room;
use Illuminate\Support\Facades\Event;
function createEmoteRoom(array $attributes = []): Room
{
return Room::create([
'title' => 'Playful Planning',
'deck_config' => [
'name' => 'Fibonacci',
'cards' => [
['key' => '3', 'label' => '3'],
['key' => '5', 'label' => '5'],
],
],
...$attributes,
]);
}
test('a participant can throw a realtime paper ball at another player', function () {
Event::fake([PaperBallThrown::class]);
$room = createEmoteRoom(['last_activity_at' => now()->subDay()]);
$sender = $room->players()->create([
'name' => 'Eric',
'is_moderator' => true,
]);
$target = $room->players()->create([
'name' => 'Anna',
'is_moderator' => false,
]);
$room->refresh();
$activity = $room->last_activity_at;
$stateVersion = $room->state_version;
$this->withSession(["room_players.{$room->id}" => $sender->id])
->postJson(route('room.emote.paper-ball.store', $room), [
'target_player_id' => $target->id,
])
->assertNoContent();
Event::assertDispatched(PaperBallThrown::class, function (PaperBallThrown $event) use ($room, $sender, $target): bool {
return $event->roomId === $room->id
&& $event->fromPlayerId === $sender->id
&& $event->targetPlayerId === $target->id
&& $event->broadcastOn()->name === "rooms.{$room->id}"
&& $event->broadcastAs() === 'room.emote.thrown'
&& array_keys($event->broadcastWith()) === [
'id',
'from_player_id',
'target_player_id',
];
});
$room->refresh();
expect($room->last_activity_at->equalTo($activity))->toBeTrue()
->and($room->state_version)->toBe($stateVersion);
});
test('a participant cannot throw a paper ball at themselves', function () {
Event::fake([PaperBallThrown::class]);
$room = createEmoteRoom();
$player = $room->players()->create([
'name' => 'Eric',
'is_moderator' => true,
]);
$this->withSession(["room_players.{$room->id}" => $player->id])
->postJson(route('room.emote.paper-ball.store', $room), [
'target_player_id' => $player->id,
])
->assertUnprocessable()
->assertJsonValidationErrors('target_player_id');
Event::assertNotDispatched(PaperBallThrown::class);
});
test('a participant cannot target a player from another room', function () {
Event::fake([PaperBallThrown::class]);
$room = createEmoteRoom();
$sender = $room->players()->create([
'name' => 'Eric',
'is_moderator' => true,
]);
$otherRoom = createEmoteRoom();
$otherPlayer = $otherRoom->players()->create([
'name' => 'Nina',
'is_moderator' => true,
]);
$this->withSession(["room_players.{$room->id}" => $sender->id])
->postJson(route('room.emote.paper-ball.store', $room), [
'target_player_id' => $otherPlayer->id,
])
->assertUnprocessable()
->assertJsonValidationErrors('target_player_id');
Event::assertNotDispatched(PaperBallThrown::class);
});