diff --git a/.env b/.env index ba59c2d..f1da44d 100644 --- a/.env +++ b/.env @@ -20,12 +20,16 @@ PTERO_SERVER_SFTP_USER=army.37a11489 PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM PTERO_SERVER_ID=37a11489 -# 🌍 Meta-WebSocket (CS2 Server Plugin) -NEXT_PUBLIC_CS2_META_WS_HOST=cs2.ironieopen.de +# META (vom CS2-Server-Plugin) +NEXT_PUBLIC_CS2_META_WS_HOST=ironieopen.local NEXT_PUBLIC_CS2_META_WS_PORT=443 NEXT_PUBLIC_CS2_META_WS_PATH=/telemetry +NEXT_PUBLIC_CS2_META_WS_SCHEME=wss -# 🖥️ Positionen / GSI-WebSocket (lokaler Aggregator) +# POS (lokaler Aggregator) NEXT_PUBLIC_CS2_POS_WS_HOST=ironieopen.local -NEXT_PUBLIC_CS2_POS_WS_PORT=8082 +NEXT_PUBLIC_CS2_POS_WS_PORT=443 NEXT_PUBLIC_CS2_POS_WS_PATH=/positions +NEXT_PUBLIC_CS2_POS_WS_SCHEME=wss + +NEXT_PUBLIC_CONNECT_HREF="steam://connect/94.130.66.149:27015/0000" diff --git a/src/app/api/matches/[matchId]/mapvote/route.ts b/src/app/api/matches/[matchId]/mapvote/route.ts index 34a6b94..36dc8e0 100644 --- a/src/app/api/matches/[matchId]/mapvote/route.ts +++ b/src/app/api/matches/[matchId]/mapvote/route.ts @@ -63,7 +63,7 @@ function buildSteps(bestOf: number, teamAId: string, teamBId: string) { { order: 3, action: 'PICK', teamId: teamBId }, { order: 4, action: 'PICK', teamId: teamAId }, { order: 5, action: 'PICK', teamId: teamBId }, - { order: 6, action: 'PICK', teamId: teamAId }, + { order: 6, action: 'DECIDER', teamId: null }, ] as const } diff --git a/src/app/api/matches/[matchId]/meta/route.ts b/src/app/api/matches/[matchId]/meta/route.ts index 66bb00b..cf2326c 100644 --- a/src/app/api/matches/[matchId]/meta/route.ts +++ b/src/app/api/matches/[matchId]/meta/route.ts @@ -1,10 +1,10 @@ -// /app/api/matches/[matchId]/meta/route.ts - import { NextResponse, type NextRequest } from 'next/server' import { prisma } from '@/app/lib/prisma' import { getServerSession } from 'next-auth' import { authOptions } from '@/app/lib/auth' import { sendServerSSEMessage } from '@/app/lib/sse-server-client' +import { MAP_OPTIONS } from '@/app/lib/mapOptions' +import { MapVoteAction } from '@/generated/prisma' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -50,6 +50,46 @@ function voteOpensAt(base: Date, leadMinutes: number) { return new Date(base.getTime() - leadMinutes * 60_000) } +// Steps-Builder für BO1/BO3/BO5 +function buildSteps(bestOf: 1 | 3 | 5, firstTeamId: string | null, secondTeamId: string | null) { + const A = firstTeamId + const B = secondTeamId + if (bestOf === 1) { + // Klassischer BO1: 6x Ban (A/B abwechselnd), danach Decider + return [ + { order: 0, action: 'BAN', teamId: A }, + { order: 1, action: 'BAN', teamId: B }, + { order: 2, action: 'BAN', teamId: A }, + { order: 3, action: 'BAN', teamId: B }, + { order: 4, action: 'BAN', teamId: A }, + { order: 5, action: 'BAN', teamId: B }, + { order: 6, action: 'DECIDER', teamId: null }, + ] as const + } + if (bestOf === 3) { + // 2x Ban, 2x Pick, 2x Ban, Decider + return [ + { order: 0, action: 'BAN', teamId: A }, + { order: 1, action: 'BAN', teamId: B }, + { order: 2, action: 'PICK', teamId: A }, + { order: 3, action: 'PICK', teamId: B }, + { order: 4, action: 'BAN', teamId: A }, + { order: 5, action: 'BAN', teamId: B }, + { order: 6, action: 'DECIDER', teamId: null }, + ] as const + } + // BO5: 2x Ban, dann 5 Picks (kein Decider) + return [ + { order: 0, action: 'BAN', teamId: A }, + { order: 1, action: 'BAN', teamId: B }, + { order: 2, action: 'PICK', teamId: A }, + { order: 3, action: 'PICK', teamId: B }, + { order: 4, action: 'PICK', teamId: A }, + { order: 5, action: 'PICK', teamId: B }, + { order: 6, action: 'PICK', teamId: A }, + ] as const +} + export async function PUT( req: NextRequest, { params }: { params: { matchId: string } } @@ -71,15 +111,20 @@ export async function PUT( map, voteLeadMinutes, // optional demoDate, + bestOf: bestOfRaw, // <- NEU } = body ?? {} + // BestOf validieren (nur 1/3/5 zulassen) + const bestOf = + [1, 3, 5].includes(Number(bestOfRaw)) ? (Number(bestOfRaw) as 1 | 3 | 5) : undefined + try { const match = await prisma.match.findUnique({ where: { id }, include: { teamA: { include: { leader: true } }, teamB: { include: { leader: true } }, - mapVote: true, + mapVote: { include: { steps: true } }, // <- Steps laden }, }) if (!match) return NextResponse.json({ error: 'Match not found' }, { status: 404 }) @@ -98,6 +143,7 @@ export async function PUT( if (typeof map !== 'undefined') updateData.map = map if (typeof teamAId !== 'undefined') updateData.teamAId = teamAId if (typeof teamBId !== 'undefined') updateData.teamBId = teamBId + if (typeof bestOf !== 'undefined') updateData.bestOf = bestOf // <- BestOf updaten const parsedMatchDate = parseDateOrNull(matchDate) if (parsedMatchDate !== undefined) updateData.matchDate = parsedMatchDate @@ -106,7 +152,6 @@ export async function PUT( if (parsedDemoDate !== undefined) { updateData.demoDate = parsedDemoDate } else if (parsedMatchDate instanceof Date) { - // demoDate mitschieben, wenn matchDate geändert und demoDate nicht gesendet wurde updateData.demoDate = parsedMatchDate } @@ -122,22 +167,29 @@ export async function PUT( (match.matchDate ?? null) ?? (match.demoDate ?? null) - // 4) Updaten & opensAt ggf. neu setzen + // 4) Updaten & ggf. MapVote anlegen/aktualisieren + Reset bei BestOf-Änderung const updated = await prisma.$transaction(async (tx) => { const m = await tx.match.update({ where: { id }, data: updateData, - include: { mapVote: true }, + include: { mapVote: { include: { steps: true } } }, }) + // MapVote-Zeit/Lead pflegen (opensAt immer aus Basiszeit+Lead) if (baseDate) { const opensAt = voteOpensAt(baseDate, leadMinutes) if (!m.mapVote) { + // Neu anlegen + const mapPool = MAP_OPTIONS.filter(o => o.active).map(o => o.key) await tx.mapVote.create({ data: { matchId: m.id, - leadMinutes, + bestOf : (m.bestOf as 1|3|5) ?? 3, + mapPool, + currentIdx: 0, + locked: false, opensAt, + leadMinutes, }, }) } else { @@ -150,26 +202,103 @@ export async function PUT( }) } } else if (leadBody !== undefined && m.mapVote) { - // Nur Lead geändert await tx.mapVote.update({ where: { id: m.mapVote.id }, data: { leadMinutes }, }) } + // --- Reset, WENN bestOf übergeben wurde und sich etwas ändert --- + if (typeof bestOf !== 'undefined') { + const vote = await tx.mapVote.findUnique({ + where: { matchId: m.id }, + include: { steps: true }, + }) + + // Wenn noch kein MapVote existiert, jetzt direkt mit Steps anlegen + if (!vote) { + const mapPool = MAP_OPTIONS.filter(o => o.active).map(o => o.key) + const opensAt = baseDate ? voteOpensAt(baseDate, leadMinutes) : null + const firstTeamId = + m.teamAId ?? null // Startheuristik: TeamA beginnt (kannst du auch randomisieren) + const secondTeamId = firstTeamId === m.teamAId ? m.teamBId ?? null : m.teamAId ?? null + const def = buildSteps(bestOf, firstTeamId, secondTeamId) + + await tx.mapVote.create({ + data: { + matchId : m.id, + bestOf : bestOf, + mapPool, + currentIdx: 0, + locked : false, + opensAt : opensAt ?? undefined, + leadMinutes, + steps : { + create: def.map(s => ({ + order : s.order, + action: s.action as MapVoteAction, + teamId: s.teamId ?? undefined, + })), + }, + }, + }) + } else { + // Prüfen: nur wenn tatsächlich abweicht → Reset + const differs = vote.bestOf !== bestOf + if (differs) { + const opensAt = baseDate ? voteOpensAt(baseDate, leadMinutes) : vote.opensAt ?? null + + // "Erstes Team" für neuen Ablauf bestimmen: + const firstTeamId = + [...vote.steps].sort((a, b) => a.order - b.order)[0]?.teamId ?? + m.teamAId ?? null + const secondTeamId = firstTeamId === m.teamAId ? m.teamBId ?? null : m.teamAId ?? null + + // Alte Steps weg + neue Steps anlegen + await tx.mapVoteStep.deleteMany({ where: { voteId: vote.id } }) + + const def = buildSteps(bestOf, firstTeamId, secondTeamId) + await tx.mapVote.update({ + where: { id: vote.id }, + data: { + bestOf, + currentIdx: 0, + locked: false, + adminEditingBy: null, + adminEditingSince: null, + ...(opensAt ? { opensAt } : {}), + steps: { + create: def.map(s => ({ + order : s.order, + action: s.action as MapVoteAction, + teamId: s.teamId ?? undefined, + })), + }, + }, + }) + + // SSE: dediziertes Reset-Event + await sendServerSSEMessage({ + type: 'map-vote-reset', + payload: { matchId: m.id }, + }) + } + } + } + return tx.match.findUnique({ - where: { id }, + where: { id: m.id }, include: { teamA: { include: { leader: true } }, teamB: { include: { leader: true } }, - mapVote: true, + mapVote: { include: { steps: true } }, }, }) }) if (!updated) return NextResponse.json({ error: 'Reload failed' }, { status: 500 }) - // Immer map-vote-updated senden, wenn es einen MapVote gibt + // Immer map-vote-updated senden, wenn es einen MapVote gibt (Zeit/Lead) if (updated.mapVote) { await sendServerSSEMessage({ type: 'map-vote-updated', @@ -181,7 +310,6 @@ export async function PUT( }) } - // 6) Response return NextResponse.json({ id: updated.id, title: updated.title, @@ -191,6 +319,7 @@ export async function PUT( matchDate: updated.matchDate, demoDate: updated.demoDate, map: updated.map, + bestOf: updated.bestOf, mapVote: updated.mapVote, }, { headers: { 'Cache-Control': 'no-store' } }) } catch (err) { diff --git a/src/app/components/CommunityMatchList.tsx b/src/app/components/CommunityMatchList.tsx index 03f96d1..d18c9e3 100644 --- a/src/app/components/CommunityMatchList.tsx +++ b/src/app/components/CommunityMatchList.tsx @@ -145,26 +145,60 @@ export default function CommunityMatchList({ matchType }: Props) { return () => { cancelled = true; clearTimeout(t) } }, [lastEvent, loadMatches]) - // Teams laden, wenn Modal aufgeht + // Teams laden, wenn Modal aufgeht (robust gegen verschiedene Response-Shapes) useEffect(() => { - if (!showCreate || teams.length) return + if (!showCreate) return + + let ignore = false + const ctrl = new AbortController() + ;(async () => { setLoadingTeams(true) try { - const res = await fetch('/api/teams', { cache: 'no-store' }) - const json = await res.json() - const opts: TeamOption[] = (json.teams ?? []).map((t: any) => ({ - id: t.id, name: t.name, logo: t.logo, - })) - setTeams(opts) + const res = await fetch('/api/teams', { + cache: 'no-store', + credentials: 'same-origin', // wichtig: Cookies mitnehmen + signal: ctrl.signal, + }) + const json = await res.json().catch(() => ({} as any)) + + // ➜ egal ob {teams: [...]}, {data: [...]}, {items: [...]} oder direkt [...] + const raw = + Array.isArray(json?.teams) ? json.teams : + Array.isArray(json?.data) ? json.data : + Array.isArray(json?.items) ? json.items : + Array.isArray(json) ? json : + [] + + const opts: TeamOption[] = raw + .map((t: any) => ({ + id: t.id ?? t._id ?? t.teamId ?? t.uuid ?? '', + name: t.name ?? t.title ?? t.displayName ?? t.tag ?? 'Unbenanntes Team', + logo: t.logo ?? t.logoUrl ?? t.image ?? null, + })) + .filter((t: TeamOption) => !!t.id && !!t.name) + + if (!ignore) setTeams(opts) } catch (e) { - console.error('[MatchList] /api/teams fehlgeschlagen:', e) - setTeams([]) + if (!ignore) { + console.error('[MatchList] /api/teams fehlgeschlagen:', e) + setTeams([]) + } } finally { - setLoadingTeams(false) + if (!ignore) setLoadingTeams(false) } })() - }, [showCreate, teams.length]) + + return () => { ignore = true; ctrl.abort() } + }, [showCreate]) + + useEffect(() => { + if (!showCreate) return + if (teams.length >= 2 && !teamAId && !teamBId) { + setTeamAId(teams[0].id) + setTeamBId(teams[1].id) + } + }, [teams, showCreate, teamAId, teamBId]) const resetCreateState = () => { setTeamAId('') @@ -490,6 +524,12 @@ export default function CommunityMatchList({ matchType }: Props) { {teamAId && teamBId && teamAId === teamBId && (

Bitte zwei unterschiedliche Teams wählen.

)} + + {!loadingTeams && showCreate && teams.length === 0 && ( +

+ Keine Teams gefunden. PrĂĽfe den /api/teams Response (erwartet id & name). +

+ )} diff --git a/src/app/components/EditMatchMetaModal.tsx b/src/app/components/EditMatchMetaModal.tsx index 9e07393..4e0fa0d 100644 --- a/src/app/components/EditMatchMetaModal.tsx +++ b/src/app/components/EditMatchMetaModal.tsx @@ -1,11 +1,10 @@ // app/components/EditMatchMetaModal.tsx 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import Modal from '@/app/components/Modal' import Alert from '@/app/components/Alert' import Select from '@/app/components/Select' -import { MAP_OPTIONS } from '../lib/mapOptions' type TeamOption = { id: string; name: string; logo?: string | null } @@ -19,9 +18,10 @@ type Props = { defaultTeamAName?: string | null defaultTeamBName?: string | null defaultDateISO?: string | null - defaultMap?: string | null + defaultMap?: string | null // bleibt im Typ für Kompatibilität, wird aber nicht mehr genutzt defaultVoteLeadMinutes?: number onSaved?: () => void + defaultBestOf?: 1 | 3 | 5 } export default function EditMatchMetaModal({ @@ -34,40 +34,44 @@ export default function EditMatchMetaModal({ defaultTeamAName, defaultTeamBName, defaultDateISO, - defaultMap, + // defaultMap, // nicht mehr genutzt defaultVoteLeadMinutes = 60, onSaved, + defaultBestOf = 3, }: Props) { - // -------- state const [title, setTitle] = useState(defaultTitle ?? '') const [teamAId, setTeamAId] = useState(defaultTeamAId ?? '') const [teamBId, setTeamBId] = useState(defaultTeamBId ?? '') + const [voteLead, setVoteLead] = useState(defaultVoteLeadMinutes) + const [date, setDate] = useState(() => { if (!defaultDateISO) return '' const d = new Date(defaultDateISO) const pad = (n: number) => String(n).padStart(2, '0') return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` }) - const [mapKey, setMapKey] = useState(defaultMap ?? 'lobby_mapvote') - const [voteLead, setVoteLead] = useState(defaultVoteLeadMinutes) + + // Nur noch BestOf editierbar + const [bestOf, setBestOf] = useState<1 | 3 | 5>(defaultBestOf) const [teams, setTeams] = useState([]) const [loadingTeams, setLoadingTeams] = useState(false) - const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) const [error, setError] = useState(null) - // -------- load teams when open + const openedOnceRef = useRef(false) + + // Teams laden useEffect(() => { if (!show) return setLoadingTeams(true) ;(async () => { try { const res = await fetch('/api/teams', { cache: 'no-store' }) - if (!res.ok) throw new Error(`Team-API: ${res.status}`) - const data = (await res.json()) as TeamOption[] - setTeams((Array.isArray(data) ? data : []).filter(t => t?.id && t?.name)) + const data = res.ok ? await res.json() : [] + const list: TeamOption[] = Array.isArray(data) ? data : (data.teams ?? []) + setTeams((list ?? []).filter((t: any) => t?.id && t?.name)) } catch (e) { console.error('[EditMatchMetaModal] load teams failed:', e) setTeams([]) @@ -77,14 +81,17 @@ export default function EditMatchMetaModal({ })() }, [show]) - // -------- reset defaults on open + // Defaults beim Öffnen (einmal) useEffect(() => { - if (!show) return + if (!show) { openedOnceRef.current = false; return } + if (openedOnceRef.current) return + openedOnceRef.current = true + setTitle(defaultTitle ?? '') setTeamAId(defaultTeamAId ?? '') setTeamBId(defaultTeamBId ?? '') - setMapKey(defaultMap ?? 'lobby_mapvote') setVoteLead(defaultVoteLeadMinutes) + if (defaultDateISO) { const d = new Date(defaultDateISO) const pad = (n: number) => String(n).padStart(2, '0') @@ -92,6 +99,8 @@ export default function EditMatchMetaModal({ } else { setDate('') } + + setBestOf(defaultBestOf ?? 3) setSaved(false) setError(null) }, [ @@ -100,31 +109,25 @@ export default function EditMatchMetaModal({ defaultTeamAId, defaultTeamBId, defaultDateISO, - defaultMap, defaultVoteLeadMinutes, + defaultBestOf, ]) - // -------- derived: options - const teamOptionsA = useMemo(() => { - // Team B nicht in A auswählbar machen - return teams - .filter(t => t.id !== teamBId) - .map(t => ({ value: t.id, label: t.name })); - }, [teams, teamBId]); - - const teamOptionsB = useMemo(() => { - // Team A nicht in B auswählbar machen - return teams - .filter(t => t.id !== teamAId) - .map(t => ({ value: t.id, label: t.name })); - }, [teams, teamAId]); - - const mapOptions = useMemo( - () => MAP_OPTIONS.map(m => ({ value: m.key, label: m.label })), - [] + // Optionen + const teamOptionsA = useMemo( + () => teams.filter(t => t.id !== teamBId).map(t => ({ value: t.id, label: t.name })), + [teams, teamBId] + ) + const teamOptionsB = useMemo( + () => teams.filter(t => t.id !== teamAId).map(t => ({ value: t.id, label: t.name })), + [teams, teamAId] ) - // -------- validation + // Hinweis-Flag: Best Of geändert? + const defaultBestOfNormalized = (defaultBestOf ?? 3) as 1 | 3 | 5 + const bestOfChanged = bestOf !== defaultBestOfNormalized + + // Validation const canSave = useMemo(() => { if (saving) return false if (!date) return false @@ -132,7 +135,7 @@ export default function EditMatchMetaModal({ return true }, [saving, date, teamAId, teamBId]) - // -------- save + // Save → nur bestOf wird (zusätzlich) übertragen; Server resettet MapVote bei Änderung const handleSave = async () => { setSaving(true) setError(null) @@ -142,8 +145,8 @@ export default function EditMatchMetaModal({ teamAId: teamAId || null, teamBId: teamBId || null, matchDate: date ? new Date(date).toISOString() : null, - map: mapKey || null, voteLeadMinutes: Number.isFinite(Number(voteLead)) ? Number(voteLead) : 60, + bestOf, // <- wichtig } const res = await fetch(`/api/matches/${matchId}/meta`, { @@ -158,9 +161,7 @@ export default function EditMatchMetaModal({ setSaved(true) onClose() - setTimeout(() => { - onSaved?.() - }, 0) + setTimeout(() => onSaved?.(), 0) } catch (e: any) { console.error('[EditMatchMetaModal] save error:', e) setError(e?.message || 'Speichern fehlgeschlagen') @@ -169,7 +170,6 @@ export default function EditMatchMetaModal({ } } - // Platzhalter mit aktuellem Namen (falls Options noch laden) const teamAPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamAName || 'Team A wählen …') const teamBPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamBName || 'Team B wählen …') @@ -241,18 +241,6 @@ export default function EditMatchMetaModal({

- {/* Map */} -
- -