build the backend structure and the fe for the Game

This commit is contained in:
2026-08-15 22:37:53 +02:00
parent c5fd3f9d79
commit 4340ca9e12
37 changed files with 1611 additions and 56 deletions
+141 -2
View File
@@ -1,11 +1,150 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreRoomRequest;
use App\Models\Deck;
use App\Models\Player;
use App\Models\Room;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
class RoomController extends Controller
{
public function create(){
return Inertia::render('rooms/CreateRoom');
public function create(Request $request)
{
$user = $request->user();
$decks = Deck::query()
->where(function ($query) use ($user) {
$query->where('is_system', true);
if ($user !== null) {
$query->orWhere('user_id', $user->id);
}
})
->orderBy('name')
->get([
'id',
'name',
'cards',
]);
return Inertia::render('rooms/Create', [
'decks' => $decks,
'defaultPlayerName' => $user?->name,
]);
}
public function store(Request $request, StoreRoomRequest $roomStoreRequest)
{
$validated = $roomStoreRequest->validated();
$user = $request->user();
$deck = Deck::query()
->whereKey($validated['deck_id'])
->where(function ($query) use ($user) {
$query->where('is_system', true);
if ($user !== null) {
$query->orWhere('user_id', $user->id);
}
})
->firstOrFail();
[$room, $player] = DB::transaction(function () use (
$validated,
$deck,
$user,
) {
$room = Room::create([
'title' => $validated['title'] ?? null,
'deck_config' => [
'id' => $deck->id,
'name' => $deck->name,
'cards' => $deck->cards,
],
]);
$player = $room->players()->create([
'user_id' => $user?->id,
'name' => $validated['player_name'],
'is_moderator' => true,
'last_seen_at' => now(),
]);
return [$room, $player];
});
$request->session()->put(
"room_players.{$room->id}",
$player->id,
);
return to_route('room.show', $room);
}
public function show(Room $room, Request $request)
{
$currentPlayer = $request->attributes->get('currentPlayer');
abort_unless($currentPlayer instanceof Player, 403);
$room->load([
'players' => fn ($query) => $query
->select(['id', 'room_id', 'name', 'is_moderator', 'created_at'])
->orderByDesc('is_moderator')
->orderBy('created_at'),
'players.votes' => fn ($query) => $query
->where('room_id', $room->id)
->where('round', $room->current_round),
]);
$players = $room->players->map(function (Player $player) use ($room) {
$vote = $player->votes->first();
return [
'id' => $player->id,
'name' => $player->name,
'is_moderator' => $player->is_moderator,
'has_voted' => $vote !== null,
'vote' => $room->revealed_at !== null ? $vote?->value : null,
];
});
$currentVote = $room->players
->firstWhere('id', $currentPlayer->id)
?->votes
->first();
return Inertia::render('rooms/Show', [
'room' => [
'id' => $room->id,
'title' => $room->title,
'join_code' => $room->join_code,
'current_round' => $room->current_round,
'revealed' => $room->revealed_at !== null,
'state_version' => $room->state_version,
'deck' => [
'name' => $room->deck_config['name'],
'cards' => $room->deck_config['cards'],
],
'players' => $players,
],
'currentPlayer' => [
'id' => $currentPlayer->id,
'name' => $currentPlayer->name,
'is_moderator' => $currentPlayer->is_moderator,
'current_vote' => $currentVote?->value,
],
]);
}
public function join(Request $request)
{
return Inertia::render('rooms/Join', [
'join_code' => $request->query('join_code'),
]);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\JoinRoomRequest;
use App\Models\Room;
class RoomPlayerController extends Controller
{
public function join(JoinRoomRequest $request)
{
$validated = $request->validated();
$user = $request->user();
$room = Room::where('join_code', '=', $validated['join_code'])->firstOrFail();
$player = $room->players()->create([
'user_id' => $user?->id,
'name' => $validated['player_name'],
'is_moderator' => false,
'last_seen_at' => now(),
]);
$request->session()->put(
"room_players.{$room->id}",
$player->id,
);
return to_route('room.show', $room);
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers;
use App\Models\Player;
use App\Models\Room;
use App\Models\Vote;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class RoundController extends Controller
{
public function reset(
Request $request,
Room $room,
): RedirectResponse {
$player = $request->attributes->get('currentPlayer');
abort_unless(
$player instanceof Player
&& $player->room_id === $room->id
&& $player->is_moderator,
403,
);
DB::transaction(function () use ($room) {
$lockedRoom = Room::query()
->lockForUpdate()
->findOrFail($room->id);
Vote::query()
->where('room_id', $lockedRoom->id)
->where('round', $lockedRoom->current_round)
->delete();
$lockedRoom->update([
'revealed_at' => null,
'state_version' => $lockedRoom->state_version + 1,
]);
});
return back();
}
public function reveal(
Request $request,
Room $room,
): RedirectResponse {
$player = $request->attributes->get('currentPlayer');
abort_unless(
$player instanceof Player
&& $player->room_id === $room->id
&& $player->is_moderator,
403,
);
DB::transaction(function () use ($room) {
$lockedRoom = Room::query()
->lockForUpdate()
->findOrFail($room->id);
if ($lockedRoom->revealed_at !== null) {
throw ValidationException::withMessages([
'room' => 'Die Karten sind bereits aufgedeckt.',
]);
}
$lockedRoom->update([
'revealed_at' => now(),
'state_version' => $lockedRoom->state_version + 1,
]);
});
return back();
}
public function nextRound(
Request $request,
Room $room,
): RedirectResponse {
$player = $request->attributes->get('currentPlayer');
abort_unless(
$player instanceof Player
&& $player->room_id === $room->id
&& $player->is_moderator,
403,
);
DB::transaction(function () use ($room) {
$lockedRoom = Room::query()
->lockForUpdate()
->findOrFail($room->id);
if ($lockedRoom->revealed_at === null) {
throw ValidationException::withMessages([
'room' => 'Die aktuelle Runde muss zuerst aufgedeckt werden.',
]);
}
$lockedRoom->update([
'current_round' => $lockedRoom->current_round + 1,
'revealed_at' => null,
'state_version' => $lockedRoom->state_version + 1,
]);
});
return back();
}
}
+48 -2
View File
@@ -2,9 +2,55 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Http\Requests\SubmitVoteRequest;
use App\Models\Player;
use App\Models\Room;
use App\Models\Vote;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class VoteController extends Controller
{
//
public function store(
SubmitVoteRequest $request,
Room $room,
): RedirectResponse {
$player = $request->attributes->get('currentPlayer');
abort_unless(
$player instanceof Player
&& $player->room_id === $room->id,
403,
);
$value = $request->validated('value');
DB::transaction(function () use ($room, $player, $value) {
$lockedRoom = Room::query()
->lockForUpdate()
->findOrFail($room->id);
if ($lockedRoom->revealed_at !== null) {
throw ValidationException::withMessages([
'value' => 'Die Karten wurden bereits aufgedeckt.',
]);
}
Vote::updateOrCreate(
[
'room_id' => $lockedRoom->id,
'player_id' => $player->id,
'round' => $lockedRoom->current_round,
],
[
'value' => $value,
],
);
$lockedRoom->increment('state_version');
});
return back();
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Middleware;
use App\Models\Room;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureRoomParticipant
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$room = $request->route('room');
if (! $room instanceof Room) {
abort(404);
}
$sessionKey = "room_players.{$room->id}";
$playerId = $request->session()->get($sessionKey);
if (! $playerId) {
return to_route('room.join', [
'join_code' => $room->join_code,
])->withErrors([
'join_code' => 'Du musst diesen Raum erst beitreten.',
]);
}
$player = $room->players()->find($playerId);
if (! $player) {
$request->session()->forget($sessionKey);
return to_route('room.join', [
'join_code' => $room->join_code,
]);
}
$request->attributes->set('currentPlayer', $player);
return $next($request);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Http\Requests;
use App\Models\Player;
use App\Models\Room;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class JoinRoomRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->merge([
'join_code' => strtoupper(trim((string) $this->input('join_code'))),
'player_name' => trim((string) $this->input('player_name')),
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$roomId = Room::query()
->where('join_code', $this->input('join_code'))
->value('id');
return [
'join_code' => ['bail', 'string', 'required', 'size:6', Rule::exists(Room::class, 'join_code')],
'player_name' => ['required', 'string', 'max:50', Rule::unique(Player::class, 'name')->where('room_id', $roomId)],
];
}
public function messages(): array
{
return [
'join_code.exists' => 'Dieser Raumcode existiert nicht.',
'join_code.size' => 'Der Raumcode muss aus sechs Zeichen bestehen.',
'player_name.unique' => 'Dieser Name wird in dem Raum bereits verwendet.',
];
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreRoomRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'title' => ['nullable', 'string', 'max:100'],
'player_name' => ['required', 'string', 'max:50'],
'deck_id' => ['required', 'integer'],
];
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SubmitVoteRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$room = $this->route('room');
return [
'value' => ['bail', 'required', 'string', Rule::in(array_column($room->deck_config['cards'], 'key'))],
];
}
public function messages(): array
{
return [
'value.required' => 'Du musst eine Karte auswählen.',
'value.in' => 'Diese Karte gehört nicht zum verwendeten Deck.',
];
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ class Deck extends Model
{
return [
'cards' => 'array',
'is_system' => 'boolean'
'is_system' => 'boolean',
];
}
+3 -3
View File
@@ -5,12 +5,12 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use phpDocumentor\Reflection\Types\Boolean;
class Player extends Model
{
protected $fillable = [
'room_id',
'room_id',
'user_id',
'name',
'is_moderator',
'last_seen_at',
@@ -58,7 +58,7 @@ class Player extends Model
return $this->is_moderator;
}
public function markAsSeen(): void
public function markAsSeen(): void
{
$this->update([
'last_seen_at' => now(),
+5 -8
View File
@@ -2,11 +2,9 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Override;
class Room extends Model
{
@@ -20,7 +18,7 @@ class Room extends Model
'state_version',
];
protected function casts() :array
protected function casts(): array
{
return [
'current_round' => 'integer',
@@ -37,15 +35,15 @@ class Room extends Model
});
}
private static function generateJoinCode(): string
private static function generateJoinCode(): string
{
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
do {
$code = '';
for($i = 0; $i < 6; $i++) {
$code .= $alphabet[random_int(0,strlen($alphabet)-1)];
for ($i = 0; $i < 6; $i++) {
$code .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
} while (static::where('join_code', $code)->exists());
@@ -53,14 +51,13 @@ class Room extends Model
}
public function players(): HasMany
{
return $this->hasMany(Player::class);
}
public function vote(): HasMany
public function votes(): HasMany
{
return $this->hasMany(Vote::class);
}
+6 -5
View File
@@ -11,22 +11,23 @@ class Vote extends Model
'round',
'value',
'room_id',
'player_id'
'player_id',
];
protected function casts(): array
{
return [
'round' => 'integer',
'round' => 'integer',
];
}
public function room(): BelongsTo {
public function room(): BelongsTo
{
return $this->belongsTo(Room::class);
}
public function player(): BelongsTo {
public function player(): BelongsTo
{
return $this->belongsTo(Player::class);
}
}