Files
Twyco 33df9e49ab
CI/CD / test_and_check_app (push) Successful in 2m22s
CI/CD / Publish immutable image (push) Successful in 1m16s
Keep guest rooms moderated and restore owner moderation
2026-09-24 15:02:05 +02:00

346 lines
11 KiB
PHP

<?php
namespace App\Services\Rooms;
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Models\Player;
use App\Models\Room;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class RoomParticipationService
{
public function __construct(
private readonly RoomRealtimeStateService $realtimeState,
) {}
public function findByJoinCode(string $joinCode): ?Room
{
if ($joinCode === '') {
return null;
}
return Room::query()
->where('join_code', $joinCode)
->first();
}
public function findExistingPlayer(
Room $room,
?User $user,
mixed $sessionPlayerId,
): ?Player {
if ($user !== null) {
return $room->players()
->where('user_id', $user->id)
->first();
}
if (! is_numeric($sessionPlayerId)) {
return null;
}
return $room->players()->find((int) $sessionPlayerId);
}
/**
* @param array{join_code: string, player_name: string} $data
* @param array<string, mixed> $sessionPlayerIds
* @return array{Room, Player}
*/
public function join(array $data, ?User $user, array $sessionPlayerIds): array
{
[$room, $player] = DB::transaction(function () use ($data, $user, $sessionPlayerIds): array {
$room = Room::query()
->where('join_code', $data['join_code'])
->lockForUpdate()
->firstOrFail();
if ($room->isClosed()) {
throw ValidationException::withMessages([
'join_code' => 'Dieser Raum ist geschlossen.',
]);
}
$player = $this->findExistingPlayer(
$room,
$user,
$sessionPlayerIds[$room->id] ?? null,
);
$shouldModerate = $this->shouldModerate($room, $user);
if ($player === null) {
$player = $room->players()->create([
'user_id' => $user?->id,
'name' => $data['player_name'],
'is_moderator' => $shouldModerate,
'last_seen_at' => now(),
]);
} else {
$player->update([
'last_seen_at' => now(),
'is_moderator' => $player->is_moderator || $shouldModerate,
]);
}
$room->update([
'last_activity_at' => now(),
'state_version' => $room->state_version + 1,
]);
return [$room, $player];
});
RoomStateChanged::dispatch(
$room->id,
$room->state_version,
RoomStateChange::PlayerJoined,
$this->realtimeState->for($room),
);
return [$room, $player];
}
public function joinEmbedded(
Room $room,
?User $user,
string $playerName,
mixed $sessionPlayerId,
): Player {
[$player, $change, $stateVersion] = DB::transaction(function () use ($room, $user, $playerName, $sessionPlayerId): array {
$lockedRoom = Room::query()
->lockForUpdate()
->findOrFail($room->id);
if ($lockedRoom->isClosed()) {
throw ValidationException::withMessages([
'join_code' => 'Dieser Raum ist geschlossen.',
]);
}
$player = $this->findExistingEmbeddedPlayer(
$lockedRoom,
$user,
$sessionPlayerId,
);
$shouldModerate = $this->shouldModerate($lockedRoom, $user);
$nameIsTaken = $lockedRoom->players()
->where('name', $playerName)
->when(
$player !== null,
fn ($query) => $query->whereKeyNot($player->id),
)
->exists();
if ($nameIsTaken) {
throw ValidationException::withMessages([
'player_name' => 'Dieser Anzeigename ist in dem Raum bereits vorhanden.',
]);
}
$change = null;
if ($player === null) {
$player = $lockedRoom->players()->create([
'user_id' => $user?->id,
'name' => $playerName,
'is_moderator' => $shouldModerate,
'last_seen_at' => now(),
]);
$change = RoomStateChange::PlayerJoined;
} else {
if ($player->name !== $playerName || ($shouldModerate && ! $player->is_moderator)) {
$change = RoomStateChange::PlayerUpdated;
}
$updates = [
'name' => $playerName,
'last_seen_at' => now(),
'is_moderator' => $player->is_moderator || $shouldModerate,
];
if (
$user !== null
&& $player->user_id === null
&& ! $lockedRoom->players()
->where('user_id', $user->id)
->whereKeyNot($player->id)
->exists()
) {
$updates['user_id'] = $user->id;
}
$player->update($updates);
}
if ($change === null) {
return [$player, null, null];
}
$lockedRoom->update([
'last_activity_at' => now(),
'state_version' => $lockedRoom->state_version + 1,
]);
return [$player, $change, $lockedRoom->state_version];
});
if ($change instanceof RoomStateChange && is_int($stateVersion)) {
RoomStateChanged::dispatch(
$room->id,
$stateVersion,
$change,
$this->realtimeState->for($room),
);
}
return $player;
}
private function shouldModerate(Room $room, ?User $user): bool
{
if ($user !== null && $room->owner_id === $user->id) {
return true;
}
return $room->owner_id === null
&& ! $room->players()->where('is_moderator', true)->exists();
}
private function findExistingEmbeddedPlayer(
Room $room,
?User $user,
mixed $sessionPlayerId,
): ?Player {
if ($user !== null) {
$userPlayer = $room->players()
->where('user_id', $user->id)
->lockForUpdate()
->first();
if ($userPlayer !== null) {
return $userPlayer;
}
}
if (is_numeric($sessionPlayerId)) {
$sessionPlayer = $room->players()
->whereKey((int) $sessionPlayerId)
->lockForUpdate()
->first();
if (
$sessionPlayer !== null
&& ($user === null || $sessionPlayer->user_id === null || $sessionPlayer->user_id === $user->id)
) {
return $sessionPlayer;
}
}
return null;
}
public function activateInstance(Room $room, Player $player, string $instanceId): Player
{
[$activePlayer, $replacedInstanceId, $stateVersion] = DB::transaction(function () use ($room, $player, $instanceId): array {
$lockedRoom = Room::query()
->lockForUpdate()
->findOrFail($room->id);
$lockedPlayer = $lockedRoom->players()
->lockForUpdate()
->findOrFail($player->id);
if ($lockedPlayer->active_instance_id === $instanceId) {
return [$lockedPlayer, null, null];
}
$replacedInstanceId = $lockedPlayer->active_instance_id;
$lockedPlayer->update([
'active_instance_id' => $instanceId,
'last_seen_at' => now(),
]);
if ($replacedInstanceId === null) {
return [$lockedPlayer, null, null];
}
$lockedRoom->update([
'last_activity_at' => now(),
'state_version' => $lockedRoom->state_version + 1,
]);
return [$lockedPlayer, $replacedInstanceId, $lockedRoom->state_version];
});
if (is_string($replacedInstanceId) && is_int($stateVersion)) {
RoomStateChanged::dispatch(
$room->id,
$stateVersion,
RoomStateChange::PlayerInstanceReplaced,
$this->realtimeState->for($room),
$activePlayer->id,
$replacedInstanceId,
);
}
return $activePlayer;
}
public function kick(
Room $room,
Player $player,
?User $user,
?Player $currentPlayer,
): void {
$stateVersion = DB::transaction(function () use ($room, $player, $user, $currentPlayer): int {
$lockedRoom = Room::query()
->lockForUpdate()
->findOrFail($room->id);
$lockedCurrentPlayer = $currentPlayer === null
? null
: $lockedRoom->players()
->lockForUpdate()
->find($currentPlayer->id);
$isOwner = $user !== null && $lockedRoom->owner_id === $user->id;
$participantCanManage = ! $lockedRoom->isClosed()
&& $lockedCurrentPlayer !== null
&& ($lockedCurrentPlayer->is_moderator
|| $lockedRoom->allow_participants_to_manage_rounds);
abort_unless($isOwner || $participantCanManage, 403);
$lockedPlayer = $lockedRoom->players()
->lockForUpdate()
->findOrFail($player->id);
abort_if(
$lockedPlayer->is_moderator
|| ($lockedRoom->owner_id !== null
&& $lockedPlayer->user_id === $lockedRoom->owner_id)
|| $lockedCurrentPlayer?->id === $lockedPlayer->id,
403,
);
$lockedPlayer->delete();
$lockedRoom->update([
'last_activity_at' => now(),
'state_version' => $lockedRoom->state_version + 1,
]);
return $lockedRoom->state_version;
});
RoomStateChanged::dispatch(
$room->id,
$stateVersion,
RoomStateChange::PlayerKicked,
$this->realtimeState->for($room),
$player->id,
);
}
}