# CourtKulture Matchmaking Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add CourtKulture matchmaking modes, fixed player metadata, King/Queen court rotation, and paste-based Reclub roster import to the existing staff session workflow.

**Architecture:** Extend the current Laravel session/court/player/queue/match tables with small metadata fields, keep controllers thin, and place business behavior in services. Use TDD around `MatchGenerationService`, `MatchResultService`, and a new `PlayerRosterImportService`; then expose the behavior through existing Blade/Bootstrap staff and public views.

**Tech Stack:** Laravel 12, PHP 8.2+, Blade, Bootstrap 5, Eloquent, Form Requests, Vite, PHPUnit.

---

## File Structure

- Create `database/migrations/2026_06_20_000013_add_matchmaking_fields_to_open_play_tables.php`
  - Adds matchmaking mode, player gender/source, court skill/rotation/holder fields, and match mode audit field.
- Modify `app/Models/OpenPlaySession.php`
  - Makes `matchmaking_mode` mass assignable.
- Modify `app/Models/Player.php`
  - Makes `gender` and `import_source` mass assignable.
- Modify `app/Models/Court.php`
  - Makes skill/rotation/holder fields mass assignable and adds holder relationships.
- Modify `app/Models/CourtMatch.php`
  - Makes `matchmaking_mode` mass assignable.
- Modify factories under `database/factories`
  - Adds safe defaults for new fields.
- Modify Form Requests under `app/Http/Requests/Staff`
  - Validates fixed skill buckets, gender choices, court rotation choices, and mode choices.
- Create `app/Http/Requests/Staff/ImportPlayersRequest.php`
  - Validates pasted roster payload and import queue option.
- Create `app/Services/PlayerRosterImportService.php`
  - Parses paste lines, creates players, skips duplicates, and queues imported players when requested.
- Create `app/Http/Controllers/Staff/PlayerImportController.php`
  - Thin controller for paste imports.
- Modify `routes/web.php`
  - Adds staff import route.
- Modify `app/Services/MatchGenerationService.php`
  - Adds mode-aware doubles generation while preserving existing singles/FIFO methods.
- Modify `app/Services/MatchResultService.php`
  - Adds King/Queen holder behavior.
- Modify Blade views:
  - `resources/views/staff/sessions/create.blade.php`
  - `resources/views/staff/sessions/show.blade.php`
  - `resources/views/public/sessions/show.blade.php`
- Modify tests:
  - `tests/Feature/CourtQueueSchemaTest.php`
  - `tests/Unit/Services/MatchGenerationServiceTest.php`
  - `tests/Unit/Services/MatchResultServiceTest.php`
  - Create `tests/Unit/Services/PlayerRosterImportServiceTest.php`
  - `tests/Feature/StaffDashboardTest.php`
  - `tests/Feature/PublicLiveSessionTest.php`

## Shared Constants

Use these exact values consistently:

```php
// Matchmaking modes
['auto_balance', 'skill_separated', 'winner_loser_group', 'mixed_doubles', 'skill_courts', 'king_queen']

// Skill buckets
['beginner', 'intermediate', 'advanced']

// Gender values
['male', 'female', 'unspecified']

// Import source values
['manual', 'reclub_paste']

// Court rotation values
['standard', 'king_queen']
```

## Task 1: Schema, Models, Factories, and Request Validation

**Files:**
- Create: `database/migrations/2026_06_20_000013_add_matchmaking_fields_to_open_play_tables.php`
- Modify: `app/Models/OpenPlaySession.php`
- Modify: `app/Models/Player.php`
- Modify: `app/Models/Court.php`
- Modify: `app/Models/CourtMatch.php`
- Modify: `database/factories/OpenPlaySessionFactory.php`
- Modify: `database/factories/PlayerFactory.php`
- Modify: `database/factories/CourtFactory.php`
- Modify: `database/factories/CourtMatchFactory.php`
- Modify: `app/Http/Requests/Staff/StoreSessionRequest.php`
- Modify: `app/Http/Requests/Staff/StorePlayerRequest.php`
- Modify: `app/Http/Requests/Staff/StoreCourtRequest.php`
- Modify: `app/Http/Requests/Staff/GenerateMatchRequest.php`
- Test: `tests/Feature/CourtQueueSchemaTest.php`
- Test: `tests/Feature/StaffDashboardTest.php`

- [ ] **Step 1: Write failing schema and validation tests**

Add assertions to `tests/Feature/CourtQueueSchemaTest.php`:

```php
public function test_matchmaking_columns_exist(): void
{
    $this->assertTrue(Schema::hasColumn('open_play_sessions', 'matchmaking_mode'));
    $this->assertTrue(Schema::hasColumn('players', 'gender'));
    $this->assertTrue(Schema::hasColumn('players', 'import_source'));
    $this->assertTrue(Schema::hasColumn('courts', 'skill_level'));
    $this->assertTrue(Schema::hasColumn('courts', 'rotation_mode'));
    $this->assertTrue(Schema::hasColumn('courts', 'holder_player_one_id'));
    $this->assertTrue(Schema::hasColumn('courts', 'holder_player_two_id'));
    $this->assertTrue(Schema::hasColumn('matches', 'matchmaking_mode'));
}
```

Add assertions to `tests/Feature/StaffDashboardTest.php`:

```php
public function test_staff_can_create_session_with_default_matchmaking_mode(): void
{
    $user = User::factory()->staff()->create();

    $response = $this->actingAs($user)->post(route('staff.sessions.store'), [
        'name' => 'Saturday Open Play',
        'starts_at' => now()->addDay()->format('Y-m-d\TH:i'),
        'status' => 'active',
        'matchmaking_mode' => 'mixed_doubles',
    ]);

    $session = OpenPlaySession::query()->where('name', 'Saturday Open Play')->firstOrFail();

    $response->assertRedirect(route('staff.sessions.show', $session));
    $this->assertSame('mixed_doubles', $session->matchmaking_mode);
}

public function test_staff_can_add_player_with_fixed_skill_and_gender(): void
{
    $user = User::factory()->staff()->create();
    $session = OpenPlaySession::factory()->create();

    $this->actingAs($user)->post(route('staff.sessions.players.store', $session), [
        'display_name' => 'Mina Slice',
        'email' => 'mina@example.com',
        'skill_level' => 'advanced',
        'gender' => 'female',
        'check_in' => '1',
    ])->assertRedirect(route('staff.sessions.show', $session));

    $this->assertDatabaseHas('players', [
        'display_name' => 'Mina Slice',
        'skill_level' => 'advanced',
        'gender' => 'female',
        'import_source' => 'manual',
        'status' => 'waiting',
    ]);
}
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```powershell
php artisan test tests\Feature\CourtQueueSchemaTest.php tests\Feature\StaffDashboardTest.php
```

Expected: FAIL because the new columns and request fields do not exist yet.

- [ ] **Step 3: Add migration**

Create `database/migrations/2026_06_20_000013_add_matchmaking_fields_to_open_play_tables.php`:

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('open_play_sessions', function (Blueprint $table) {
            $table->string('matchmaking_mode')->default('auto_balance')->after('status')->index();
        });

        Schema::table('players', function (Blueprint $table) {
            $table->string('gender')->default('unspecified')->after('skill_level')->index();
            $table->string('import_source')->default('manual')->after('gender')->index();
        });

        Schema::table('courts', function (Blueprint $table) {
            $table->string('skill_level')->nullable()->after('sort_order')->index();
            $table->string('rotation_mode')->default('standard')->after('skill_level')->index();
            $table->foreignId('holder_player_one_id')->nullable()->after('rotation_mode')->constrained('players')->nullOnDelete();
            $table->foreignId('holder_player_two_id')->nullable()->after('holder_player_one_id')->constrained('players')->nullOnDelete();
        });

        Schema::table('matches', function (Blueprint $table) {
            $table->string('matchmaking_mode')->nullable()->after('active_court_key')->index();
        });
    }

    public function down(): void
    {
        Schema::table('matches', function (Blueprint $table) {
            $table->dropColumn('matchmaking_mode');
        });

        Schema::table('courts', function (Blueprint $table) {
            $table->dropConstrainedForeignId('holder_player_two_id');
            $table->dropConstrainedForeignId('holder_player_one_id');
            $table->dropColumn(['rotation_mode', 'skill_level']);
        });

        Schema::table('players', function (Blueprint $table) {
            $table->dropColumn(['import_source', 'gender']);
        });

        Schema::table('open_play_sessions', function (Blueprint $table) {
            $table->dropColumn('matchmaking_mode');
        });
    }
};
```

- [ ] **Step 4: Update model fillables and relationships**

Modify `app/Models/OpenPlaySession.php` fillable:

```php
protected $fillable = [
    'name',
    'venue',
    'public_token',
    'starts_at',
    'ends_at',
    'status',
    'matchmaking_mode',
];
```

Modify `app/Models/Player.php` fillable:

```php
protected $fillable = [
    'open_play_session_id',
    'display_name',
    'email',
    'skill_level',
    'gender',
    'import_source',
    'status',
    'wins',
    'losses',
    'games_played',
];
```

Modify `app/Models/Court.php`:

```php
use Illuminate\Database\Eloquent\Relations\BelongsTo;

protected $fillable = [
    'open_play_session_id',
    'name',
    'sort_order',
    'skill_level',
    'rotation_mode',
    'holder_player_one_id',
    'holder_player_two_id',
    'status',
];

public function holderPlayerOne(): BelongsTo
{
    return $this->belongsTo(Player::class, 'holder_player_one_id');
}

public function holderPlayerTwo(): BelongsTo
{
    return $this->belongsTo(Player::class, 'holder_player_two_id');
}
```

Modify `app/Models/CourtMatch.php` fillable:

```php
protected $fillable = [
    'open_play_session_id',
    'court_id',
    'active_court_key',
    'matchmaking_mode',
    'status',
    'started_at',
    'completed_at',
];
```

- [ ] **Step 5: Update factories**

Modify `database/factories/OpenPlaySessionFactory.php`:

```php
'matchmaking_mode' => 'auto_balance',
```

Modify `database/factories/PlayerFactory.php`:

```php
'skill_level' => fake()->randomElement(['beginner', 'intermediate', 'advanced']),
'gender' => 'unspecified',
'import_source' => 'manual',
```

Modify `database/factories/CourtFactory.php`:

```php
'skill_level' => null,
'rotation_mode' => 'standard',
'holder_player_one_id' => null,
'holder_player_two_id' => null,
```

Modify `database/factories/CourtMatchFactory.php`:

```php
'matchmaking_mode' => null,
```

- [ ] **Step 6: Update Form Requests**

Add this method to `StoreSessionRequest`:

```php
protected function prepareForValidation(): void
{
    $this->merge([
        'matchmaking_mode' => $this->input('matchmaking_mode', 'auto_balance'),
    ]);
}
```

Modify `StoreSessionRequest` rules:

```php
'matchmaking_mode' => ['required', Rule::in([
    'auto_balance',
    'skill_separated',
    'winner_loser_group',
    'mixed_doubles',
    'skill_courts',
    'king_queen',
])],
```

Add this method to `StorePlayerRequest`:

```php
protected function prepareForValidation(): void
{
    $this->merge([
        'skill_level' => $this->input('skill_level', 'intermediate'),
        'gender' => $this->input('gender', 'unspecified'),
    ]);
}
```

Modify `StorePlayerRequest` rules:

```php
'skill_level' => ['required', Rule::in(['beginner', 'intermediate', 'advanced'])],
'gender' => ['required', Rule::in(['male', 'female', 'unspecified'])],
```

Add this method to `StoreCourtRequest`:

```php
protected function prepareForValidation(): void
{
    $this->merge([
        'rotation_mode' => $this->input('rotation_mode', 'standard'),
    ]);
}
```

Modify `StoreCourtRequest` rules:

```php
'skill_level' => ['nullable', Rule::in(['beginner', 'intermediate', 'advanced'])],
'rotation_mode' => ['required', Rule::in(['standard', 'king_queen'])],
```

Modify `GenerateMatchRequest` rules:

```php
'format' => ['required', Rule::in(['singles', 'doubles'])],
'matchmaking_mode' => ['nullable', Rule::in([
    'auto_balance',
    'skill_separated',
    'winner_loser_group',
    'mixed_doubles',
    'skill_courts',
    'king_queen',
])],
```

- [ ] **Step 7: Update controllers for new fields**

Modify `PlayerController@store`:

```php
$player = Player::query()->create([
    ...$request->safe()->only(['display_name', 'email', 'skill_level', 'gender']),
    'import_source' => 'manual',
    'open_play_session_id' => $session->id,
    'status' => 'active',
]);
```

Modify `CourtController@store`:

```php
Court::query()->create([
    ...$request->safe()->only(['name', 'sort_order', 'status', 'skill_level', 'rotation_mode']),
    'open_play_session_id' => $session->id,
]);
```

- [ ] **Step 8: Run tests to verify pass**

Run:

```powershell
php artisan test tests\Feature\CourtQueueSchemaTest.php tests\Feature\StaffDashboardTest.php
```

Expected: PASS for schema and field-creation behavior.

- [ ] **Step 9: Commit or record checkpoint**

Run:

```powershell
git rev-parse --show-toplevel
```

If Git works, run:

```powershell
git add database app tests
git commit -m "feat: add matchmaking metadata schema"
```

If Git returns `fatal: not a git repository`, do not run commit commands and record this checkpoint in the final summary.

## Task 2: Paste-Based Reclub Roster Import

**Files:**
- Create: `app/Services/PlayerRosterImportService.php`
- Create: `app/Http/Requests/Staff/ImportPlayersRequest.php`
- Create: `app/Http/Controllers/Staff/PlayerImportController.php`
- Modify: `routes/web.php`
- Test: `tests/Unit/Services/PlayerRosterImportServiceTest.php`
- Test: `tests/Feature/StaffDashboardTest.php`

- [ ] **Step 1: Write failing import service tests**

Create `tests/Unit/Services/PlayerRosterImportServiceTest.php`:

```php
<?php

namespace Tests\Unit\Services;

use App\Models\OpenPlaySession;
use App\Models\Player;
use App\Services\PlayerRosterImportService;
use App\Services\QueueService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PlayerRosterImportServiceTest extends TestCase
{
    use RefreshDatabase;

    public function test_import_creates_players_from_pasted_lines(): void
    {
        $session = OpenPlaySession::factory()->create();
        $service = new PlayerRosterImportService(new QueueService());

        $summary = $service->import($session, implode("\n", [
            'Ada Rally, Advanced, Female',
            'Ben Drop, ben@example.com, Beginner, Male',
            'Casey Kitchen',
        ]), checkIn: false);

        $this->assertSame(3, $summary['created']);
        $this->assertSame(0, $summary['queued']);
        $this->assertSame([], $summary['invalid_lines']);
        $this->assertDatabaseHas('players', [
            'display_name' => 'Ada Rally',
            'skill_level' => 'advanced',
            'gender' => 'female',
            'import_source' => 'reclub_paste',
            'status' => 'active',
        ]);
        $this->assertDatabaseHas('players', [
            'display_name' => 'Casey Kitchen',
            'skill_level' => 'intermediate',
            'gender' => 'unspecified',
        ]);
    }

    public function test_import_skips_duplicate_names_and_can_check_players_into_queue(): void
    {
        $session = OpenPlaySession::factory()->create();
        Player::factory()->for($session)->create(['display_name' => 'Ada Rally']);
        $service = new PlayerRosterImportService(new QueueService());

        $summary = $service->import($session, "Ada Rally\nDina Drive, Intermediate, Female", checkIn: true);

        $this->assertSame(1, $summary['created']);
        $this->assertSame(1, $summary['queued']);
        $this->assertSame(['Ada Rally'], $summary['skipped_duplicates']);
        $this->assertDatabaseHas('queue_entries', ['open_play_session_id' => $session->id, 'status' => 'waiting']);
    }

    public function test_import_reports_invalid_lines(): void
    {
        $session = OpenPlaySession::factory()->create();
        $service = new PlayerRosterImportService(new QueueService());

        $summary = $service->import($session, "Valid Name, Beginner, Male\nBad Name, Expert, Unknown", checkIn: false);

        $this->assertSame(1, $summary['created']);
        $this->assertCount(1, $summary['invalid_lines']);
        $this->assertSame(2, $summary['invalid_lines'][0]['line']);
    }
}
```

- [ ] **Step 2: Write failing import feature test**

Add to `tests/Feature/StaffDashboardTest.php`:

```php
public function test_staff_can_paste_import_reclub_roster(): void
{
    $user = User::factory()->staff()->create();
    $session = OpenPlaySession::factory()->create();

    $this->actingAs($user)->post(route('staff.sessions.players.import', $session), [
        'roster' => "Ada Rally, Advanced, Female\nBen Drop, Beginner, Male",
        'check_in' => '1',
    ])->assertRedirect(route('staff.sessions.show', $session));

    $this->assertDatabaseHas('players', [
        'display_name' => 'Ada Rally',
        'import_source' => 'reclub_paste',
        'status' => 'waiting',
    ]);
    $this->assertSame(2, $session->queueEntries()->where('status', 'waiting')->count());
}

public function test_non_staff_cannot_import_roster(): void
{
    $user = User::factory()->create(['is_staff' => false]);
    $session = OpenPlaySession::factory()->create();

    $this->actingAs($user)->post(route('staff.sessions.players.import', $session), [
        'roster' => 'Ada Rally',
    ])->assertForbidden();
}
```

- [ ] **Step 3: Run tests to verify failure**

Run:

```powershell
php artisan test tests\Unit\Services\PlayerRosterImportServiceTest.php tests\Feature\StaffDashboardTest.php
```

Expected: FAIL because the service, request, controller, and route do not exist.

- [ ] **Step 4: Create import request**

Create `app/Http/Requests/Staff/ImportPlayersRequest.php`:

```php
<?php

namespace App\Http\Requests\Staff;

use Illuminate\Foundation\Http\FormRequest;

class ImportPlayersRequest extends FormRequest
{
    public function authorize(): bool
    {
        return (bool) $this->user()?->is_staff;
    }

    public function rules(): array
    {
        return [
            'roster' => ['required', 'string', 'max:12000'],
            'check_in' => ['nullable', 'boolean'],
        ];
    }

    public function withValidator($validator): void
    {
        $validator->after(function ($validator) {
            $lines = preg_split('/\R/u', (string) $this->input('roster'));
            $filled = collect($lines)->filter(fn ($line) => trim((string) $line) !== '');

            if ($filled->count() > 200) {
                $validator->errors()->add('roster', 'Import up to 200 players at a time.');
            }
        });
    }
}
```

- [ ] **Step 5: Create import service**

Create `app/Services/PlayerRosterImportService.php`:

```php
<?php

namespace App\Services;

use App\Models\OpenPlaySession;
use App\Models\Player;
use Illuminate\Support\Facades\DB;

class PlayerRosterImportService
{
    public function __construct(private readonly QueueService $queueService)
    {
    }

    public function import(OpenPlaySession $session, string $roster, bool $checkIn = false): array
    {
        return DB::transaction(function () use ($session, $roster, $checkIn) {
            $existing = $session->players()
                ->pluck('display_name')
                ->mapWithKeys(fn ($name) => [mb_strtolower($name) => true]);

            $summary = [
                'created' => 0,
                'queued' => 0,
                'skipped_duplicates' => [],
                'invalid_lines' => [],
            ];

            foreach (preg_split('/\R/u', $roster) as $index => $line) {
                $line = trim((string) $line);

                if ($line === '') {
                    continue;
                }

                $parsed = $this->parseLine($line);

                if ($parsed === null) {
                    $summary['invalid_lines'][] = ['line' => $index + 1, 'value' => $line];
                    continue;
                }

                $key = mb_strtolower($parsed['display_name']);

                if (isset($existing[$key])) {
                    $summary['skipped_duplicates'][] = $parsed['display_name'];
                    continue;
                }

                $player = Player::query()->create([
                    'open_play_session_id' => $session->id,
                    'display_name' => $parsed['display_name'],
                    'email' => $parsed['email'],
                    'skill_level' => $parsed['skill_level'],
                    'gender' => $parsed['gender'],
                    'import_source' => 'reclub_paste',
                    'status' => 'active',
                ]);

                $existing[$key] = true;
                $summary['created']++;

                if ($checkIn) {
                    $this->queueService->addPlayerToQueue($session, $player);
                    $summary['queued']++;
                }
            }

            return $summary;
        });
    }

    private function parseLine(string $line): ?array
    {
        $parts = array_map('trim', str_getcsv($line));

        if (count($parts) < 1 || count($parts) > 4 || $parts[0] === '') {
            return null;
        }

        $name = $parts[0];
        $email = null;
        $skill = 'intermediate';
        $gender = 'unspecified';

        if (count($parts) === 2) {
            if (str_contains($parts[1], '@')) {
                $email = $parts[1];
            } else {
                $skill = $this->normalizeSkill($parts[1]) ?? '';
            }
        }

        if (count($parts) === 3) {
            if (str_contains($parts[1], '@')) {
                $email = $parts[1];
                $skill = $this->normalizeSkill($parts[2]) ?? '';
            } else {
                $skill = $this->normalizeSkill($parts[1]) ?? '';
                $gender = $this->normalizeGender($parts[2]) ?? '';
            }
        }

        if (count($parts) === 4) {
            $email = $parts[1] !== '' ? $parts[1] : null;
            $skill = $this->normalizeSkill($parts[2]) ?? '';
            $gender = $this->normalizeGender($parts[3]) ?? '';
        }

        if ($skill === '' || $gender === '') {
            return null;
        }

        return [
            'display_name' => $name,
            'email' => $email,
            'skill_level' => $skill,
            'gender' => $gender,
        ];
    }

    private function normalizeSkill(string $value): ?string
    {
        return match (mb_strtolower(trim($value))) {
            '', 'i', 'int', 'intermediate' => 'intermediate',
            'b', 'beg', 'beginner' => 'beginner',
            'a', 'adv', 'advanced' => 'advanced',
            default => null,
        };
    }

    private function normalizeGender(string $value): ?string
    {
        return match (mb_strtolower(trim($value))) {
            '', 'u', 'unspecified', 'unknown', 'other' => 'unspecified',
            'm', 'male' => 'male',
            'f', 'female' => 'female',
            default => null,
        };
    }
}
```

- [ ] **Step 6: Create controller and route**

Create `app/Http/Controllers/Staff/PlayerImportController.php`:

```php
<?php

namespace App\Http\Controllers\Staff;

use App\Http\Controllers\Controller;
use App\Http\Requests\Staff\ImportPlayersRequest;
use App\Models\OpenPlaySession;
use App\Services\PlayerRosterImportService;
use Illuminate\Http\RedirectResponse;

class PlayerImportController extends Controller
{
    public function __construct(private readonly PlayerRosterImportService $imports)
    {
    }

    public function store(ImportPlayersRequest $request, OpenPlaySession $session): RedirectResponse
    {
        $summary = $this->imports->import(
            $session,
            (string) $request->validated('roster'),
            $request->boolean('check_in'),
        );

        return redirect()
            ->route('staff.sessions.show', $session)
            ->with('status', "Imported {$summary['created']} players. Queued {$summary['queued']}. Skipped ".count($summary['skipped_duplicates']).'. Invalid '.count($summary['invalid_lines']).'.');
    }
}
```

Modify `routes/web.php` imports:

```php
use App\Http\Controllers\Staff\PlayerImportController;
```

Add route inside the staff group:

```php
Route::post('sessions/{session}/players/import', [PlayerImportController::class, 'store'])
    ->name('sessions.players.import');
```

- [ ] **Step 7: Run tests to verify pass**

Run:

```powershell
php artisan test tests\Unit\Services\PlayerRosterImportServiceTest.php tests\Feature\StaffDashboardTest.php
```

Expected: PASS for import behavior.

- [ ] **Step 8: Commit or record checkpoint**

Run:

```powershell
git rev-parse --show-toplevel
```

If Git works, run:

```powershell
git add app routes tests
git commit -m "feat: add paste roster import"
```

If Git returns `fatal: not a git repository`, do not run commit commands and record this checkpoint in the final summary.

## Task 3: Mode-Aware Match Generation

**Files:**
- Modify: `app/Services/MatchGenerationService.php`
- Modify: `app/Http/Controllers/Staff/MatchController.php`
- Test: `tests/Unit/Services/MatchGenerationServiceTest.php`
- Test: `tests/Feature/StaffDashboardTest.php`

- [ ] **Step 1: Write failing match generation tests**

Add to `tests/Unit/Services/MatchGenerationServiceTest.php`:

```php
public function test_auto_balance_splits_teams_by_skill_strength(): void
{
    $session = OpenPlaySession::factory()->create(['matchmaking_mode' => 'auto_balance']);
    Court::factory()->for($session)->create(['status' => 'available']);
    $players = Player::factory()->count(4)->sequence(
        ['display_name' => 'Advanced One', 'skill_level' => 'advanced'],
        ['display_name' => 'Advanced Two', 'skill_level' => 'advanced'],
        ['display_name' => 'Beginner One', 'skill_level' => 'beginner'],
        ['display_name' => 'Beginner Two', 'skill_level' => 'beginner'],
    )->for($session)->create();
    $queue = new QueueService();
    $service = new MatchGenerationService($queue);

    foreach ($players as $player) {
        $queue->addPlayerToQueue($session, $player);
    }

    $match = $service->generateMatchForMode($session, 'doubles');
    $teams = $match->teams()->orderBy('team_number')->get();

    $this->assertSame('auto_balance', $match->matchmaking_mode);
    $this->assertSame(
        [[$players[0]->id, $players[2]->id], [$players[1]->id, $players[3]->id]],
        [
            [$teams[0]->player_one_id, $teams[0]->player_two_id],
            [$teams[1]->player_one_id, $teams[1]->player_two_id],
        ],
    );
}

public function test_skill_separated_uses_first_skill_bucket_with_four_waiting_players(): void
{
    $session = OpenPlaySession::factory()->create(['matchmaking_mode' => 'skill_separated']);
    Court::factory()->for($session)->create(['status' => 'available']);
    $players = Player::factory()->count(6)->sequence(
        ['display_name' => 'Beginner 1', 'skill_level' => 'beginner'],
        ['display_name' => 'Advanced 1', 'skill_level' => 'advanced'],
        ['display_name' => 'Advanced 2', 'skill_level' => 'advanced'],
        ['display_name' => 'Advanced 3', 'skill_level' => 'advanced'],
        ['display_name' => 'Advanced 4', 'skill_level' => 'advanced'],
        ['display_name' => 'Beginner 2', 'skill_level' => 'beginner'],
    )->for($session)->create();
    $queue = new QueueService();

    foreach ($players as $player) {
        $queue->addPlayerToQueue($session, $player);
    }

    $match = (new MatchGenerationService($queue))->generateMatchForMode($session, 'doubles');
    $selectedIds = $match->teams->flatMap(fn ($team) => [$team->player_one_id, $team->player_two_id])->all();

    $this->assertEqualsCanonicalizing($players->slice(1, 4)->pluck('id')->all(), $selectedIds);
}

public function test_mixed_doubles_requires_two_male_and_two_female_players(): void
{
    $session = OpenPlaySession::factory()->create(['matchmaking_mode' => 'mixed_doubles']);
    Court::factory()->for($session)->create(['status' => 'available']);
    $players = Player::factory()->count(4)->sequence(
        ['display_name' => 'Female 1', 'gender' => 'female', 'skill_level' => 'advanced'],
        ['display_name' => 'Female 2', 'gender' => 'female', 'skill_level' => 'beginner'],
        ['display_name' => 'Male 1', 'gender' => 'male', 'skill_level' => 'advanced'],
        ['display_name' => 'Male 2', 'gender' => 'male', 'skill_level' => 'beginner'],
    )->for($session)->create();
    $queue = new QueueService();

    foreach ($players as $player) {
        $queue->addPlayerToQueue($session, $player);
    }

    $match = (new MatchGenerationService($queue))->generateMatchForMode($session, 'doubles');

    foreach ($match->teams()->with(['playerOne', 'playerTwo'])->get() as $team) {
        $this->assertEqualsCanonicalizing(['female', 'male'], [$team->playerOne->gender, $team->playerTwo->gender]);
    }
}

public function test_skill_courts_uses_matching_court_and_players(): void
{
    $session = OpenPlaySession::factory()->create(['matchmaking_mode' => 'skill_courts']);
    Court::factory()->for($session)->create(['name' => 'Beginner Court', 'skill_level' => 'beginner', 'status' => 'available']);
    $advancedCourt = Court::factory()->for($session)->create(['name' => 'Advanced Court', 'skill_level' => 'advanced', 'status' => 'available']);
    $players = Player::factory()->count(4)->for($session)->create(['skill_level' => 'advanced']);
    $queue = new QueueService();

    foreach ($players as $player) {
        $queue->addPlayerToQueue($session, $player);
    }

    $match = (new MatchGenerationService($queue))->generateMatchForMode($session, 'doubles');

    $this->assertSame($advancedCourt->id, $match->court_id);
}

public function test_winner_loser_group_throws_when_no_recent_result_group_has_four_players(): void
{
    $session = OpenPlaySession::factory()->create(['matchmaking_mode' => 'winner_loser_group']);
    Court::factory()->for($session)->create(['status' => 'available']);
    $players = Player::factory()->count(4)->for($session)->create();
    $queue = new QueueService();

    foreach ($players as $player) {
        $queue->addPlayerToQueue($session, $player);
    }

    $this->expectException(RuntimeException::class);
    $this->expectExceptionMessage('Not enough waiting players with matching recent result groups.');

    (new MatchGenerationService($queue))->generateMatchForMode($session, 'doubles');
}

public function test_winner_loser_group_selects_four_players_with_matching_recent_result(): void
{
    $session = OpenPlaySession::factory()->create(['matchmaking_mode' => 'winner_loser_group']);
    Court::factory()->for($session)->create(['status' => 'available']);
    $players = Player::factory()->count(4)->for($session)->create();
    $historyCourt = Court::factory()->for($session)->create(['status' => 'closed']);
    $firstHistory = CourtMatch::factory()->for($session)->for($historyCourt, 'court')->create(['status' => 'completed']);
    $secondHistory = CourtMatch::factory()->for($session)->for($historyCourt, 'court')->create(['status' => 'completed']);
    MatchTeam::factory()->for($firstHistory, 'match')->create([
        'team_number' => 1,
        'player_one_id' => $players[0]->id,
        'player_two_id' => $players[1]->id,
        'is_winner' => true,
    ]);
    MatchTeam::factory()->for($secondHistory, 'match')->create([
        'team_number' => 1,
        'player_one_id' => $players[2]->id,
        'player_two_id' => $players[3]->id,
        'is_winner' => true,
    ]);
    $queue = new QueueService();

    foreach ($players as $player) {
        $queue->addPlayerToQueue($session, $player);
    }

    $match = (new MatchGenerationService($queue))->generateMatchForMode($session, 'doubles', 'winner_loser_group');
    $selectedIds = $match->teams->flatMap(fn ($team) => [$team->player_one_id, $team->player_two_id])->all();

    $this->assertEqualsCanonicalizing($players->pluck('id')->all(), $selectedIds);
}
```

Ensure this test file imports `RuntimeException`:

```php
use App\Models\CourtMatch;
use App\Models\MatchTeam;
use RuntimeException;
```

Add to `tests/Feature/StaffDashboardTest.php`:

```php
public function test_staff_can_override_matchmaking_mode_when_generating_match(): void
{
    $user = User::factory()->staff()->create();
    $session = OpenPlaySession::factory()->create(['matchmaking_mode' => 'auto_balance']);
    Court::factory()->for($session)->create(['status' => 'available']);
    $players = Player::factory()->count(4)->for($session)->create(['skill_level' => 'advanced']);
    $queueService = new QueueService();

    foreach ($players as $player) {
        $queueService->addPlayerToQueue($session, $player);
    }

    $this->actingAs($user)->post(route('staff.sessions.matches.generate', $session), [
        'format' => 'doubles',
        'matchmaking_mode' => 'skill_separated',
    ])->assertRedirect(route('staff.sessions.show', $session));

    $this->assertDatabaseHas('matches', [
        'open_play_session_id' => $session->id,
        'matchmaking_mode' => 'skill_separated',
        'status' => 'in_progress',
    ]);
}
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```powershell
php artisan test tests\Unit\Services\MatchGenerationServiceTest.php tests\Feature\StaffDashboardTest.php
```

Expected: FAIL because `generateMatchForMode` and mode selectors do not exist.

- [ ] **Step 3: Add mode-aware public entry point**

Modify `app/Services/MatchGenerationService.php`. Keep existing methods and add:

```php
private const SKILL_WEIGHTS = [
    'beginner' => 1,
    'intermediate' => 2,
    'advanced' => 3,
];

public function generateMatchForMode(OpenPlaySession $session, string $format, ?string $mode = null): CourtMatch
{
    $mode ??= $session->matchmaking_mode ?? 'auto_balance';

    if ($format === 'singles') {
        return $this->generateMatch($session, 2, true, $mode);
    }

    return DB::transaction(function () use ($session, $mode) {
        [$court, $teams] = $this->selectDoublesForMode($session, $mode);

        $match = CourtMatch::query()->create([
            'open_play_session_id' => $session->id,
            'court_id' => $court->id,
            'active_court_key' => $this->activeCourtKey($session, $court),
            'matchmaking_mode' => $mode,
            'status' => 'in_progress',
            'started_at' => now(),
        ]);

        $this->createTeam($match, 1, $teams[0][0], $teams[0][1]);
        $this->createTeam($match, 2, $teams[1][0], $teams[1][1]);

        $court->forceFill(['status' => 'in_use'])->save();
        $this->queueService->markQueueEntriesPlaying($session, collect($teams)->flatten());

        return $match->refresh()->load('teams');
    });
}
```

Change private `generateMatch` signature and creation so existing methods can store a mode:

```php
private function generateMatch(OpenPlaySession $session, int $playerCount, bool $singles, ?string $mode = null): CourtMatch
```

Inside `CourtMatch::query()->create([...])`, add:

```php
'matchmaking_mode' => $mode,
```

- [ ] **Step 4: Add selectors**

Add these methods to `MatchGenerationService`:

```php
private function selectDoublesForMode(OpenPlaySession $session, string $mode): array
{
    return match ($mode) {
        'skill_separated' => $this->selectSkillSeparatedDoubles($session),
        'winner_loser_group' => $this->selectWinnerLoserDoubles($session),
        'mixed_doubles' => $this->selectMixedDoubles($session),
        'skill_courts' => $this->selectSkillCourtDoubles($session),
        'king_queen' => $this->selectKingQueenDoubles($session),
        default => $this->selectAutoBalancedDoubles($session),
    };
}

private function selectAutoBalancedDoubles(OpenPlaySession $session): array
{
    $players = $this->waitingPlayers($session)->take(4)->values();

    if ($players->count() < 4) {
        throw new RuntimeException('Not enough waiting players to generate this match.');
    }

    return [$this->availableCourtOrFail($session), $this->balancedTeams($players)];
}

private function selectSkillSeparatedDoubles(OpenPlaySession $session): array
{
    $players = $this->waitingPlayers($session);
    $skillOrder = $players->pluck('skill_level')->unique()->values();

    foreach ($skillOrder as $skill) {
        $group = $players->where('skill_level', $skill)->take(4)->values();

        if ($skill !== null && $group->count() === 4) {
            return [$this->availableCourtOrFail($session), $this->balancedTeams($group)];
        }
    }

    throw new RuntimeException('Not enough waiting players in the same skill bucket.');
}

private function selectMixedDoubles(OpenPlaySession $session): array
{
    $players = $this->waitingPlayers($session);
    $female = $players->where('gender', 'female')->take(2)->values();
    $male = $players->where('gender', 'male')->take(2)->values();

    if ($female->count() < 2 || $male->count() < 2) {
        throw new RuntimeException('Mixed Doubles needs at least two female and two male waiting players.');
    }

    $teams = $this->balancedMixedTeams($female, $male);

    return [$this->availableCourtOrFail($session), $teams];
}

private function selectSkillCourtDoubles(OpenPlaySession $session): array
{
    $players = $this->waitingPlayers($session);
    $courts = $this->availableCourtQuery($session)->lockForUpdate()->get();

    foreach ($courts->whereNotNull('skill_level') as $court) {
        $group = $players->where('skill_level', $court->skill_level)->take(4)->values();

        if ($group->count() === 4) {
            return [$court, $this->balancedTeams($group)];
        }
    }

    $openCourt = $courts->firstWhere('skill_level', null);

    if ($openCourt !== null && $players->count() >= 4) {
        return [$openCourt, $this->balancedTeams($players->take(4)->values())];
    }

    throw new RuntimeException('No skill court has enough eligible waiting players.');
}

private function selectWinnerLoserDoubles(OpenPlaySession $session): array
{
    $players = $this->waitingPlayers($session);

    foreach (['winner', 'loser'] as $resultGroup) {
        $group = $players
            ->filter(fn (Player $player) => $this->recentResultGroup($player) === $resultGroup)
            ->take(4)
            ->values();

        if ($group->count() === 4) {
            return [$this->availableCourtOrFail($session), $this->balancedTeams($group)];
        }
    }

    throw new RuntimeException('Not enough waiting players with matching recent result groups.');
}
```

Add helpers:

```php
private function waitingPlayers(OpenPlaySession $session): Collection
{
    return Player::query()
        ->select('players.*')
        ->join('queue_entries', 'queue_entries.player_id', '=', 'players.id')
        ->where('queue_entries.open_play_session_id', $session->id)
        ->where('queue_entries.status', 'waiting')
        ->whereNull('queue_entries.deleted_at')
        ->orderBy('queue_entries.position')
        ->orderBy('queue_entries.queued_at')
        ->get();
}

private function availableCourtOrFail(OpenPlaySession $session): Court
{
    $court = $this->availableCourtQuery($session)->lockForUpdate()->first();

    if ($court === null) {
        throw new RuntimeException('No available court for this session.');
    }

    return $court;
}

private function balancedTeams(Collection $players): array
{
    $players = $players->values();
    $pairings = [
        [[$players[0], $players[1]], [$players[2], $players[3]]],
        [[$players[0], $players[2]], [$players[1], $players[3]]],
        [[$players[0], $players[3]], [$players[1], $players[2]]],
    ];

    usort($pairings, fn ($a, $b) => $this->teamGap($a) <=> $this->teamGap($b));

    return $pairings[0];
}

private function balancedMixedTeams(Collection $female, Collection $male): array
{
    $pairings = [
        [[$female[0], $male[0]], [$female[1], $male[1]]],
        [[$female[0], $male[1]], [$female[1], $male[0]]],
    ];

    usort($pairings, fn ($a, $b) => $this->teamGap($a) <=> $this->teamGap($b));

    return $pairings[0];
}

private function teamGap(array $pairing): int
{
    return abs($this->teamStrength($pairing[0]) - $this->teamStrength($pairing[1]));
}

private function teamStrength(array $players): int
{
    return collect($players)->sum(fn (Player $player) => self::SKILL_WEIGHTS[$player->skill_level] ?? 2);
}

private function recentResultGroup(Player $player): ?string
{
    $team = MatchTeam::query()
        ->where(function ($query) use ($player) {
            $query->where('player_one_id', $player->id)
                ->orWhere('player_two_id', $player->id);
        })
        ->whereNotNull('is_winner')
        ->whereHas('match', fn ($query) => $query->where('status', 'completed'))
        ->latest('updated_at')
        ->first();

    if ($team === null) {
        return null;
    }

    return $team->is_winner ? 'winner' : 'loser';
}
```

Add missing imports:

```php
use App\Models\MatchTeam;
```

- [ ] **Step 5: Update controller**

Modify `MatchController@generate`:

```php
$this->matches->generateMatchForMode(
    $session,
    (string) $request->validated('format'),
    $request->validated('matchmaking_mode'),
);
```

- [ ] **Step 6: Run tests to verify pass**

Run:

```powershell
php artisan test tests\Unit\Services\MatchGenerationServiceTest.php tests\Feature\StaffDashboardTest.php
```

Expected: PASS for mode-aware match generation except King/Queen, which is added in the next task.

- [ ] **Step 7: Commit or record checkpoint**

Run:

```powershell
git rev-parse --show-toplevel
```

If Git works, run:

```powershell
git add app tests
git commit -m "feat: add matchmaking mode selectors"
```

If Git returns `fatal: not a git repository`, do not run commit commands and record this checkpoint in the final summary.

## Task 4: King and Queen Court Holder Flow

**Files:**
- Modify: `app/Services/MatchGenerationService.php`
- Modify: `app/Services/MatchResultService.php`
- Test: `tests/Unit/Services/MatchGenerationServiceTest.php`
- Test: `tests/Unit/Services/MatchResultServiceTest.php`

- [ ] **Step 1: Write failing King/Queen tests**

Add to `tests/Unit/Services/MatchResultServiceTest.php`:

```php
public function test_king_queen_result_keeps_winners_as_court_holders_and_rotates_losers(): void
{
    $session = OpenPlaySession::factory()->create(['matchmaking_mode' => 'king_queen']);
    $court = Court::factory()->for($session)->create([
        'status' => 'available',
        'rotation_mode' => 'king_queen',
    ]);
    $players = Player::factory()->count(4)->for($session)->create();
    $queue = new QueueService();

    foreach ($players as $player) {
        $queue->addPlayerToQueue($session, $player);
    }

    $match = (new MatchGenerationService($queue))->generateMatchForMode($session, 'doubles', 'king_queen');
    (new MatchResultService($queue))->recordWinningTeam($match, 1, 11, 7, returnPlayersToQueue: true);

    $court->refresh();

    $this->assertSame('available', $court->status);
    $this->assertSame($match->teams->firstWhere('team_number', 1)->player_one_id, $court->holder_player_one_id);
    $this->assertSame($match->teams->firstWhere('team_number', 1)->player_two_id, $court->holder_player_two_id);
    $this->assertSame('playing', Player::find($court->holder_player_one_id)->status);
    $this->assertSame('waiting', Player::find($match->teams->firstWhere('team_number', 2)->player_one_id)->status);
}
```

Add to `tests/Unit/Services/MatchGenerationServiceTest.php`:

```php
public function test_king_queen_next_match_uses_holders_and_two_challengers(): void
{
    $session = OpenPlaySession::factory()->create(['matchmaking_mode' => 'king_queen']);
    $holders = Player::factory()->count(2)->for($session)->create(['status' => 'playing']);
    $challengers = Player::factory()->count(2)->for($session)->create(['status' => 'active']);
    $court = Court::factory()->for($session)->create([
        'status' => 'available',
        'rotation_mode' => 'king_queen',
        'holder_player_one_id' => $holders[0]->id,
        'holder_player_two_id' => $holders[1]->id,
    ]);
    $queue = new QueueService();

    foreach ($challengers as $player) {
        $queue->addPlayerToQueue($session, $player);
    }

    $match = (new MatchGenerationService($queue))->generateMatchForMode($session, 'doubles', 'king_queen');
    $teams = $match->teams()->orderBy('team_number')->get();

    $this->assertSame($court->id, $match->court_id);
    $this->assertSame([$holders[0]->id, $holders[1]->id], [$teams[0]->player_one_id, $teams[0]->player_two_id]);
    $this->assertSame([$challengers[0]->id, $challengers[1]->id], [$teams[1]->player_one_id, $teams[1]->player_two_id]);
}
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```powershell
php artisan test tests\Unit\Services\MatchGenerationServiceTest.php tests\Unit\Services\MatchResultServiceTest.php
```

Expected: FAIL because holder behavior has not been implemented.

- [ ] **Step 3: Implement King/Queen selector**

Add to `MatchGenerationService`:

```php
private function selectKingQueenDoubles(OpenPlaySession $session): array
{
    $court = Court::query()
        ->where('open_play_session_id', $session->id)
        ->where('status', 'available')
        ->where('rotation_mode', 'king_queen')
        ->orderBy('sort_order')
        ->orderBy('id')
        ->lockForUpdate()
        ->first();

    if ($court === null) {
        throw new RuntimeException('No available King/Queen court for this session.');
    }

    if ($court->holder_player_one_id !== null && $court->holder_player_two_id !== null) {
        $challengers = $this->waitingPlayers($session)->take(2)->values();

        if ($challengers->count() < 2) {
            throw new RuntimeException('King/Queen needs two waiting challengers.');
        }

        $holders = collect([
            Player::query()->findOrFail($court->holder_player_one_id),
            Player::query()->findOrFail($court->holder_player_two_id),
        ]);

        return [$court, [[$holders[0], $holders[1]], [$challengers[0], $challengers[1]]]];
    }

    $players = $this->waitingPlayers($session)->take(4)->values();

    if ($players->count() < 4) {
        throw new RuntimeException('Not enough waiting players to start King/Queen.');
    }

    return [$court, $this->balancedTeams($players)];
}
```

- [ ] **Step 4: Implement King/Queen result behavior**

Modify `MatchResultService@recordWinningTeam` after team stats update and before returning players:

```php
$isKingQueen = $match->court->rotation_mode === 'king_queen'
    || $match->matchmaking_mode === 'king_queen';
```

Replace court availability and player return loop with:

```php
$match->court->forceFill(['status' => 'available'])->save();

if ($isKingQueen) {
    $winningPlayers = $this->playersForTeam($winningTeam)->values();

    $match->court->forceFill([
        'holder_player_one_id' => $winningPlayers[0]?->id,
        'holder_player_two_id' => $winningPlayers[1]?->id,
    ])->save();
} else {
    $match->court->forceFill([
        'holder_player_one_id' => null,
        'holder_player_two_id' => null,
    ])->save();
}

foreach ($match->teams as $team) {
    $isWinner = (int) $team->team_number === $winningTeamNumber;

    if ($isKingQueen && $isWinner) {
        continue;
    }

    foreach ($this->playersForTeam($team) as $player) {
        $this->queueService->returnPlayerAfterMatch($match->openPlaySession, $player, $returnPlayersToQueue);
    }
}
```

- [ ] **Step 5: Run tests to verify pass**

Run:

```powershell
php artisan test tests\Unit\Services\MatchGenerationServiceTest.php tests\Unit\Services\MatchResultServiceTest.php
```

Expected: PASS for King/Queen generation and results.

- [ ] **Step 6: Commit or record checkpoint**

Run:

```powershell
git rev-parse --show-toplevel
```

If Git works, run:

```powershell
git add app tests
git commit -m "feat: add king queen court rotation"
```

If Git returns `fatal: not a git repository`, do not run commit commands and record this checkpoint in the final summary.

## Task 5: Staff and Public Blade UI

**Files:**
- Modify: `resources/views/staff/sessions/create.blade.php`
- Modify: `resources/views/staff/sessions/show.blade.php`
- Modify: `resources/views/public/sessions/show.blade.php`
- Modify: `resources/css/app.css`
- Modify: `app/Http/Controllers/Staff/SessionController.php`
- Modify: `app/Http/Controllers/PublicLiveSessionController.php`
- Test: `tests/Feature/StaffDashboardTest.php`
- Test: `tests/Feature/PublicLiveSessionTest.php`

- [ ] **Step 1: Write failing UI tests**

Add to `tests/Feature/StaffDashboardTest.php`:

```php
public function test_staff_dashboard_shows_matchmaking_import_skill_and_gender_controls(): void
{
    $user = User::factory()->staff()->create();
    $session = OpenPlaySession::factory()->create([
        'name' => 'Club Open Play',
        'matchmaking_mode' => 'auto_balance',
    ]);
    Court::factory()->for($session)->create([
        'name' => 'Challenge Court',
        'skill_level' => 'advanced',
        'rotation_mode' => 'king_queen',
    ]);
    Player::factory()->for($session)->create([
        'display_name' => 'Ada Rally',
        'skill_level' => 'advanced',
        'gender' => 'female',
    ]);

    $this->actingAs($user)
        ->get(route('staff.sessions.show', $session))
        ->assertOk()
        ->assertSee('Default: Auto-Balance')
        ->assertSee('Import Reclub Roster')
        ->assertSee('Matchmaking Override')
        ->assertSee('Mixed Doubles')
        ->assertSee('King/Queen')
        ->assertSee('Advanced')
        ->assertSee('Female');
}
```

Add to `tests/Feature/PublicLiveSessionTest.php`:

```php
public function test_public_live_session_shows_matchmaking_and_skill_labels(): void
{
    $session = OpenPlaySession::factory()->create([
        'matchmaking_mode' => 'skill_courts',
    ]);
    Court::factory()->for($session)->create([
        'name' => 'Advanced Court',
        'skill_level' => 'advanced',
        'rotation_mode' => 'king_queen',
    ]);
    Player::factory()->for($session)->create([
        'display_name' => 'Ada Rally',
        'skill_level' => 'advanced',
        'gender' => 'female',
    ]);

    $this->get(route('public.sessions.show', $session->public_token))
        ->assertOk()
        ->assertSee('Skill Courts')
        ->assertSee('Advanced Court')
        ->assertSee('Advanced')
        ->assertSee('King/Queen');
}
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```powershell
php artisan test tests\Feature\StaffDashboardTest.php tests\Feature\PublicLiveSessionTest.php
```

Expected: FAIL because the controls and labels are not rendered.

- [ ] **Step 3: Load holder relationships in controllers**

Modify session loaders in `SessionController@show` and `PublicLiveSessionController`:

```php
'courts.holderPlayerOne',
'courts.holderPlayerTwo',
```

- [ ] **Step 4: Add mode labels in Blade**

At the top of `resources/views/staff/sessions/show.blade.php`, before `@section('content')`, add:

```php
@php
    $matchmakingModes = [
        'auto_balance' => 'Auto-Balance',
        'skill_separated' => 'Skill Separated',
        'winner_loser_group' => 'Winner/Loser Group',
        'mixed_doubles' => 'Mixed Doubles',
        'skill_courts' => 'Skill Courts',
        'king_queen' => 'King/Queen',
    ];
    $skillLabels = ['beginner' => 'Beginner', 'intermediate' => 'Intermediate', 'advanced' => 'Advanced'];
    $genderLabels = ['male' => 'Male', 'female' => 'Female', 'unspecified' => 'Unspecified'];
@endphp
```

Use the same arrays in `create.blade.php` and `public/sessions/show.blade.php` where labels are needed.

- [ ] **Step 5: Update session create form**

In `resources/views/staff/sessions/create.blade.php`, add a select:

```blade
<div class="mb-3">
    <label for="matchmaking_mode" class="form-label">Default Matchmaking</label>
    <select id="matchmaking_mode" name="matchmaking_mode" class="form-select">
        @foreach ($matchmakingModes as $value => $label)
            <option value="{{ $value }}" @selected(old('matchmaking_mode', 'auto_balance') === $value)>{{ $label }}</option>
        @endforeach
    </select>
</div>
```

- [ ] **Step 6: Update staff dashboard controls**

In Quick Add Player, replace skill input with selects:

```blade
<div class="row g-2">
    <div class="col-12 col-md-6">
        <label for="skill_level" class="form-label">Skill Level</label>
        <select id="skill_level" name="skill_level" class="form-select">
            @foreach ($skillLabels as $value => $label)
                <option value="{{ $value }}" @selected(old('skill_level', 'intermediate') === $value)>{{ $label }}</option>
            @endforeach
        </select>
    </div>
    <div class="col-12 col-md-6">
        <label for="gender" class="form-label">Gender</label>
        <select id="gender" name="gender" class="form-select">
            @foreach ($genderLabels as $value => $label)
                <option value="{{ $value }}" @selected(old('gender', 'unspecified') === $value)>{{ $label }}</option>
            @endforeach
        </select>
    </div>
</div>
```

Add Reclub import card:

```blade
<section class="cq-card p-3 h-100 mt-3">
    <h2 class="cq-section-title mb-3">Import Reclub Roster</h2>
    <form method="POST" action="{{ route('staff.sessions.players.import', $session) }}">
        @csrf
        <div class="mb-3">
            <label for="roster" class="form-label">Roster</label>
            <textarea id="roster" name="roster" class="form-control" rows="6" placeholder="Ada Rally, Advanced, Female&#10;Ben Drop, ben@example.com, Beginner, Male"></textarea>
        </div>
        <div class="form-check mb-3">
            <input id="import_check_in" name="check_in" value="1" type="checkbox" class="form-check-input" checked>
            <label for="import_check_in" class="form-check-label">Check imported players into queue</label>
        </div>
        <button type="submit" class="btn btn-cq btn-cq-active w-100">Import Players</button>
    </form>
</section>
```

In Courts form, add:

```blade
<div class="col-6">
    <label for="court_skill_level" class="form-label">Skill Court</label>
    <select id="court_skill_level" name="skill_level" class="form-select">
        <option value="">Any Skill</option>
        @foreach ($skillLabels as $value => $label)
            <option value="{{ $value }}">{{ $label }}</option>
        @endforeach
    </select>
</div>
<div class="col-6">
    <label for="rotation_mode" class="form-label">Rotation</label>
    <select id="rotation_mode" name="rotation_mode" class="form-select">
        <option value="standard">Standard</option>
        <option value="king_queen">King/Queen</option>
    </select>
</div>
```

In Generate Match card, add a mode select to both generation forms or replace the two forms with one form:

```blade
<form method="POST" action="{{ route('staff.sessions.matches.generate', $session) }}" class="d-grid gap-3">
    @csrf
    <div>
        <label for="format" class="form-label">Format</label>
        <select id="format" name="format" class="form-select">
            <option value="doubles">Doubles</option>
            <option value="singles">Singles</option>
        </select>
    </div>
    <div>
        <label for="matchmaking_mode" class="form-label">Matchmaking Override</label>
        <select id="matchmaking_mode" name="matchmaking_mode" class="form-select">
            @foreach ($matchmakingModes as $value => $label)
                <option value="{{ $value }}" @selected($session->matchmaking_mode === $value)>{{ $label }}</option>
            @endforeach
        </select>
        <div class="form-text">Default: {{ $matchmakingModes[$session->matchmaking_mode] ?? 'Auto-Balance' }}</div>
    </div>
    <button type="submit" class="btn btn-cq btn-cq-active w-100">Generate Match</button>
</form>
```

Display skill/gender badges in queue and standings:

```blade
<span class="badge ck-badge">{{ $skillLabels[$player->skill_level] ?? 'Intermediate' }}</span>
<span class="badge ck-badge">{{ $genderLabels[$player->gender] ?? 'Unspecified' }}</span>
```

- [ ] **Step 7: Update public view labels**

Show session mode near page header:

```blade
<span class="badge ck-badge">{{ $matchmakingModes[$session->matchmaking_mode] ?? 'Auto-Balance' }}</span>
```

Show court skill and rotation:

```blade
<div class="small text-secondary">
    {{ $court->skill_level ? ($skillLabels[$court->skill_level] ?? ucfirst($court->skill_level)) : 'Any Skill' }}
    @if ($court->rotation_mode === 'king_queen')
        · King/Queen
    @endif
</div>
```

Show match mode:

```blade
<div class="small text-secondary">{{ $matchmakingModes[$match->matchmaking_mode] ?? 'Standard Match' }}</div>
```

- [ ] **Step 8: Add light badge CSS**

Add to `resources/css/app.css`:

```css
.ck-meta-badges {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
}
```

- [ ] **Step 9: Run tests to verify pass**

Run:

```powershell
php artisan test tests\Feature\StaffDashboardTest.php tests\Feature\PublicLiveSessionTest.php
```

Expected: PASS for staff/public UI assertions.

- [ ] **Step 10: Commit or record checkpoint**

Run:

```powershell
git rev-parse --show-toplevel
```

If Git works, run:

```powershell
git add app resources tests
git commit -m "feat: add matchmaking dashboard controls"
```

If Git returns `fatal: not a git repository`, do not run commit commands and record this checkpoint in the final summary.

## Task 6: Full Verification and Build

**Files:**
- No source file edits unless verification exposes a concrete failure.

- [ ] **Step 1: Run full PHP test suite**

Run:

```powershell
php artisan test
```

Expected: PASS for all tests.

- [ ] **Step 2: Run frontend build**

Run:

```powershell
cmd /c npm run build
```

Expected: PASS and Vite emits assets into `public/build`.

- [ ] **Step 3: Inspect route list for staff routes**

Run:

```powershell
php artisan route:list --path=staff
```

Expected: route list includes `staff.sessions.players.import` and existing staff session/match routes.

- [ ] **Step 4: Final status check**

Run:

```powershell
git status --short
```

Expected if Git is healthy: concise list of changed files.

If Git still returns `fatal: not a git repository`, include that exact limitation in the final summary.

- [ ] **Step 5: Final response**

Summarize:

- Matchmaking modes implemented.
- Reclub paste import implemented.
- Staff/public UI updates.
- Tests and build command results.
- Changed files.
- Any Git limitation.

## Self-Review

Spec coverage:

- Session default plus per-match override: Task 1 and Task 5.
- Paste-based Reclub roster import: Task 2 and Task 5.
- Fixed skill buckets: Task 1, Task 2, Task 3, Task 5.
- Gender for Mixed Doubles: Task 1, Task 2, Task 3, Task 5.
- Auto-Balance: Task 3.
- Skill Separated: Task 3.
- Winner/Loser Group: Task 3.
- Mixed Doubles: Task 3.
- Skill Courts: Task 3 and Task 5.
- King and Queen: Task 4 and Task 5.
- Mobile-friendly staff/public Blade updates: Task 5.
- Tests and build verification: Task 6.

Placeholder scan:

- The plan uses exact field names, route names, file paths, commands, and expected outcomes.
- The plan avoids deferred requirements and does not leave implementation behavior unspecified.

Type consistency:

- `matchmaking_mode`, `skill_level`, `gender`, `import_source`, `rotation_mode`, `holder_player_one_id`, and `holder_player_two_id` are used consistently across schema, models, requests, services, views, and tests.
