mirror of
https://github.com/ericasch6/scrum-poker-internal.git
synced 2026-10-13 15:12:09 +00:00
feat: added laravel reverb and websocket, also emote throwing added
This commit is contained in:
+16
-1
@@ -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}"
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
return [
|
||||
$lockedRoom->players()
|
||||
->where('user_id', $user->id)
|
||||
->firstOrFail();
|
||||
->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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
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' => DB::raw('state_version + 1'),
|
||||
'updated_at' => $closedAt,
|
||||
'state_version' => $room->state_version + 1,
|
||||
]);
|
||||
|
||||
return $room->state_version;
|
||||
});
|
||||
|
||||
if ($stateVersion !== null) {
|
||||
RoomStateChanged::dispatch(
|
||||
(string) $roomId,
|
||||
$stateVersion,
|
||||
RoomStateChange::RoomClosed,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+909
-1
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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",
|
||||
|
||||
Generated
+34
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]>;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
// Room updates use public UUID channels and do not need authorization callbacks.
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user