From 5531a68da0b8c82325d6ff17243b6605408aaba7 Mon Sep 17 00:00:00 2001
From: Linrador
Date: Mon, 8 Sep 2025 22:53:28 +0200
Subject: [PATCH] updated
---
.env | 12 +-
.../api/matches/[matchId]/mapvote/route.ts | 2 +-
src/app/api/matches/[matchId]/meta/route.ts | 153 ++++++++++-
src/app/components/CommunityMatchList.tsx | 64 ++++-
src/app/components/EditMatchMetaModal.tsx | 121 +++++----
src/app/components/MatchDetails.tsx | 3 +
src/app/components/MatchReadyOverlay.tsx | 106 +++++---
src/app/components/radar/LiveRadar.tsx | 241 +++++++++++++++---
src/app/components/radar/MetaSocket.tsx | 35 ++-
src/app/components/radar/PositionsSocket.tsx | 11 +-
src/generated/prisma/edge.js | 4 +-
src/generated/prisma/index.js | 4 +-
12 files changed, 586 insertions(+), 170 deletions(-)
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 */}
-
- Map
-
-
-
{/* Vote-Lead */}
Map-Vote lead (Minuten)
@@ -267,6 +255,33 @@ export default function EditMatchMetaModal({
Zeit vor Matchstart, zu der das Vote öffnet (Standard 60).
+
+ {/* Nur noch Best Of */}
+
+
Modus (Best of)
+
+ {[1, 3, 5].map(bo => (
+ setBestOf(bo as 1|3|5)}
+ className={`px-3 py-1.5 rounded-lg text-sm border
+ ${bestOf === bo
+ ? 'bg-blue-600 text-white border-blue-600'
+ : 'bg-transparent border-gray-300 dark:border-neutral-700 text-gray-800 dark:text-neutral-200'}`}
+ >
+ BO{bo}
+
+ ))}
+
+
+ {bestOfChanged && (
+
+ Du hast den Modus von BO{defaultBestOfNormalized} auf BO{bestOf} geändert.
+ Beim Speichern wird der Map-Vote zurĂĽckgesetzt (alle bisherigen Schritte/Maps werden verworfen).
+
+ )}
+
)
diff --git a/src/app/components/MatchDetails.tsx b/src/app/components/MatchDetails.tsx
index 8a75cec..916f43d 100644
--- a/src/app/components/MatchDetails.tsx
+++ b/src/app/components/MatchDetails.tsx
@@ -560,6 +560,9 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
defaultDateISO={match.matchDate ?? match.demoDate ?? null}
defaultMap={match.map ?? null}
defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60}
+ // ⬇️ neu:
+ defaultBestOf={(match.bestOf as 1 | 3 | 5) ?? 3}
+ defaultSeries={extractSeriesMaps(match)} // Array mit map-Keys (kann '' enthalten)
onSaved={() => { setTimeout(() => router.refresh(), 0) }}
/>
)}
diff --git a/src/app/components/MatchReadyOverlay.tsx b/src/app/components/MatchReadyOverlay.tsx
index 4e21464..fe73acd 100644
--- a/src/app/components/MatchReadyOverlay.tsx
+++ b/src/app/components/MatchReadyOverlay.tsx
@@ -39,12 +39,17 @@ export default function MatchReadyOverlay({
deadlineAt,
onTimeout,
forceGif,
- connectHref = 'steam://connect/cs2.ironieopen.de:27015/ironie',
+ connectHref
}: Props) {
const { data: session } = useSession()
const mySteamId = session?.user?.steamId
const { lastEvent } = useSSEStore()
+
+ const ENV_CONNECT_HREF = process.env.NEXT_PUBLIC_CONNECT_HREF
+ const DEFAULT_CONNECT_HREF = 'steam://connect/94.130.66.149:27015/0000'
+ const effectiveConnectHref = connectHref ?? ENV_CONNECT_HREF ?? DEFAULT_CONNECT_HREF
+
const [now, setNow] = useState(() => Date.now())
const [startedAt] = useState(() => Date.now())
const fallbackDeadline = useMemo(() => startedAt + 20_000, [startedAt])
@@ -54,7 +59,7 @@ export default function MatchReadyOverlay({
// UI-States
const [accepted, setAccepted] = useState(false)
const [finished, setFinished] = useState(false)
- const [showWaitHint, setShowWaitHint] = useState(false)
+ const [showWaitHint, setShowWaitHint] = useState(false) // ⬅️ nutzt du unten zum Ausblenden des Countdowns
const [connecting, setConnecting] = useState(false)
const isVisible = open || accepted || showWaitHint
@@ -144,7 +149,7 @@ export default function MatchReadyOverlay({
useEffect(() => {
if (!isVisible) { setShowBackdrop(false); setShowContent(false); return }
setShowBackdrop(true)
- const id = setTimeout(() => setShowContent(true), 2000)
+ const id = setTimeout(() => setShowContent(true), 300) // vorher: 2000
return () => clearTimeout(id)
}, [isVisible])
@@ -158,6 +163,7 @@ export default function MatchReadyOverlay({
}, [accepted, loadReady])
// SSE
+ const { lastEvent: le } = useSSEStore()
useEffect(() => {
if (!lastEvent) return
const type = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type
@@ -230,9 +236,15 @@ export default function MatchReadyOverlay({
}, [isVisible])
// ----- AUDIO: Beeps starten/stoppen -----
+ // Beeps erst starten, wenn der Content sichtbar ist
useEffect(() => {
- if (!isVisible) { stopBeeps(); audioStartedRef.current = false; return }
+ if (!showContent) { // vorher: if (!isVisible)
+ stopBeeps()
+ audioStartedRef.current = false
+ return
+ }
if (audioStartedRef.current) return
+
let cleanup = () => {}
;(async () => {
const ok = await ensureAudioUnlocked()
@@ -250,8 +262,9 @@ export default function MatchReadyOverlay({
window.removeEventListener('keydown', onGesture)
}
})()
+
return () => { cleanup(); stopBeeps() }
- }, [isVisible])
+ }, [showContent]) // vorher: [isVisible]
// ----- countdown / timeout -----
const rafRef = useRef(null)
@@ -269,11 +282,11 @@ export default function MatchReadyOverlay({
try { sound.play('loading') } catch {}
const doConnect = () => {
- try { window.location.href = connectHref }
+ try { window.location.href = effectiveConnectHref }
catch {
try {
const a = document.createElement('a')
- a.href = connectHref
+ a.href = effectiveConnectHref
document.body.appendChild(a)
a.click()
a.remove()
@@ -283,7 +296,7 @@ export default function MatchReadyOverlay({
}
setTimeout(doConnect, 2000)
} else {
- setShowWaitHint(true)
+ setShowWaitHint(true) // ⬅️ triggert Hinweis „Dein Team wartet auf dich!“
}
return
}
@@ -293,16 +306,6 @@ export default function MatchReadyOverlay({
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
}, [isVisible, effectiveDeadline, accepted, finished, connectHref, onTimeout])
- // ---- Präsenz → Rahmenfarbe ----
- const borderByPresence = (s: Presence | undefined): string => {
- switch (s) {
- case 'online': return 'border-[#2ecc71]'
- case 'away': return 'border-yellow-400'
- case 'offline':
- default: return 'border-white/20'
- }
- }
-
// 🔎 Map-Icon aus MAP_OPTIONS ermitteln
const mapIconUrl = useMemo(() => {
const norm = (s?: string | null) => (s ?? '').trim().toLowerCase()
@@ -325,7 +328,10 @@ export default function MatchReadyOverlay({
const p = participants[i]
const isReady = p ? !!readyMap[p.steamId] : false
const presence: Presence = (p && statusMap[p.steamId]) || 'offline'
- const borderCls = borderByPresence(presence)
+ const borderCls =
+ presence === 'online' ? 'border-[#2ecc71]' :
+ presence === 'away' ? 'border-yellow-400' :
+ 'border-white/20'
return (
@@ -389,24 +395,41 @@ export default function MatchReadyOverlay({
].join(' ')}
>
{/* Map */}
-
+
- {/* Deko-Layer */}
+ {/* Deko-Layer (Gif/Video) */}
{useGif ? (
-
+
) : (
)}
+ {/* 🔽 NEU: dunkler Gradient wie bei „Gewählte Maps“ */}
+
+
{/* Inhalt */}
)}
diff --git a/src/app/components/radar/LiveRadar.tsx b/src/app/components/radar/LiveRadar.tsx
index 3694a11..a78f13c 100644
--- a/src/app/components/radar/LiveRadar.tsx
+++ b/src/app/components/radar/LiveRadar.tsx
@@ -1,5 +1,3 @@
-// /app/components/radar/LiveRadar.tsx
-
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -14,7 +12,8 @@ const UI = {
dirLenRel: 0.70,
dirMinLenPx: 6,
lineWidthRel: 0.25,
- stroke: '#ffffff',
+ stroke: '#ffffff', // normaler Outline (weiĂź)
+ bombStroke: '#ef4444', // Outline wenn Bombe (rot)
fillCT: '#3b82f6',
fillT: '#f59e0b',
dirColor: 'auto' as 'auto' | string,
@@ -30,6 +29,17 @@ const UI = {
teamStrokeT: '#f59e0b',
minRadiusPx: 6,
},
+ death: {
+ stroke: '#9ca3af', // graues X
+ lineWidthPx: 2,
+ sizePx: 10,
+ },
+ trail: {
+ maxPoints: 60,
+ fadeMs: 1500,
+ stroke: 'rgba(60,60,60,0.7)',
+ widthPx: 2,
+ }
}
/* ───────── helpers ───────── */
@@ -49,20 +59,72 @@ function mapTeam(t: any): 'T' | 'CT' | string {
return String(t ?? '')
}
-function buildWsUrl(prefix: 'CS2_META' | 'CS2_POS') {
- const host = process.env[`NEXT_PUBLIC_${prefix}_WS_HOST`] || '127.0.0.1'
- const port = String(process.env[`NEXT_PUBLIC_${prefix}_WS_PORT`] || (prefix === 'CS2_META' ? '443' : '8082'))
- const path = process.env[`NEXT_PUBLIC_${prefix}_WS_PATH`] || '/telemetry'
-
- // Heuristik: wenn explizit 443 -> wss, wenn Seite https und Host != localhost -> wss, sonst ws
- const isLocal = ['127.0.0.1', 'localhost', '::1'].includes(host)
- const pageHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'
- const proto = (port === '443' || (!isLocal && pageHttps)) ? 'wss' : 'ws'
-
- const portPart = (port === '80' || port === '443') ? '' : `:${port}`
- return `${proto}://${host}${portPart}${path}`
+// Versuche robust zu erkennen, ob ein Spieler die Bombe hat
+function detectHasBomb(src: any): boolean {
+ const flags = [
+ 'hasBomb','has_bomb','bomb','c4','hasC4','carryingBomb','bombCarrier','isBombCarrier'
+ ]
+ for (const k of flags) {
+ if (typeof src?.[k] === 'boolean') return !!src[k]
+ if (typeof src?.[k] === 'string') {
+ const s = String(src[k]).toLowerCase()
+ if (s === 'true' || s === '1' || s === 'c4' || s.includes('bomb')) return true
+ }
+ }
+ const arrays = [src?.weapons, src?.inventory, src?.items]
+ for (const arr of arrays) {
+ if (!arr) continue
+ if (Array.isArray(arr)) {
+ if (arr.some((w:any)=>
+ typeof w === 'string'
+ ? w.toLowerCase().includes('c4') || w.toLowerCase().includes('bomb')
+ : (w?.name||w?.type||w?.weapon||'').toLowerCase().includes('c4') ||
+ (w?.name||w?.type||w?.weapon||'').toLowerCase().includes('bomb')
+ )) return true
+ } else if (typeof arr === 'object') {
+ const vals = Object.values(arr)
+ if (vals.some((w:any)=>
+ typeof w === 'string'
+ ? w.toLowerCase().includes('c4') || w.toLowerCase().includes('bomb')
+ : (w?.name||w?.type||w?.weapon||'').toLowerCase().includes('c4') ||
+ (w?.name||w?.type||w?.weapon||'').toLowerCase().includes('bomb')
+ )) return true
+ }
+ }
+ return false
}
+// URL-Builder
+function makeWsUrl(
+ host?: string,
+ port?: string,
+ path?: string,
+ scheme?: string
+) {
+ const h = (host ?? '').trim() || '127.0.0.1'
+ const p = (port ?? '').trim() || '8081'
+ const pa = (path ?? '').trim() || '/telemetry'
+ const sch = (scheme ?? '').toLowerCase()
+ const pageHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'
+ const useWss = sch === 'wss' || (sch !== 'ws' && (p === '443' || pageHttps))
+ const proto = useWss ? 'wss' : 'ws'
+ const portPart = (p === '80' || p === '443') ? '' : `:${p}`
+ return `${proto}://${h}${portPart}${pa}`
+}
+
+const metaUrl = makeWsUrl(
+ process.env.NEXT_PUBLIC_CS2_META_WS_HOST,
+ process.env.NEXT_PUBLIC_CS2_META_WS_PORT,
+ process.env.NEXT_PUBLIC_CS2_META_WS_PATH,
+ process.env.NEXT_PUBLIC_CS2_META_WS_SCHEME
+)
+
+const posUrl = makeWsUrl(
+ process.env.NEXT_PUBLIC_CS2_POS_WS_HOST,
+ process.env.NEXT_PUBLIC_CS2_POS_WS_PORT,
+ process.env.NEXT_PUBLIC_CS2_POS_WS_PATH,
+ process.env.NEXT_PUBLIC_CS2_POS_WS_SCHEME
+)
const RAD2DEG = 180 / Math.PI
const normalizeDeg = (d: number) => (d % 360 + 360) % 360
@@ -83,6 +145,7 @@ type PlayerState = {
z: number
yaw?: number | null
alive?: boolean
+ hasBomb?: boolean
}
type Grenade = {
id: string
@@ -94,22 +157,36 @@ type Grenade = {
expiresAt?: number | null
team?: 'T' | 'CT' | string | null
}
+type DeathMarker = { id: string; x: number; y: number; t: number }
+type Trail = { id: string; kind: Grenade['kind']; pts: {x:number,y:number}[]; lastSeen: number }
+
type Overview = { posX: number; posY: number; scale: number; rotate?: number }
type Mapper = (xw: number, yw: number) => { x: number; y: number }
/* ───────── Komponente ───────── */
export default function LiveRadar() {
- // WS-Status separat anzeigen
+ // WS-Status
const [metaWsStatus, setMetaWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
const [posWsStatus, setPosWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
- // Zustand
+ // Map
const [activeMapKey, setActiveMapKey] = useState(null)
+ // Spieler
const playersRef = useRef>(new Map())
const [players, setPlayers] = useState([])
+
+ // Grenaden + Trails
const grenadesRef = useRef>(new Map())
const [grenades, setGrenades] = useState([])
+ const trailsRef = useRef>(new Map())
+ const [trails, setTrails] = useState([])
+
+ // Death-Marker
+ const deathMarkersRef = useRef([])
+ const [deathMarkers, setDeathMarkers] = useState([])
+
+ // Flush
const flushTimer = useRef(null)
const scheduleFlush = () => {
if (flushTimer.current != null) return
@@ -117,9 +194,10 @@ export default function LiveRadar() {
flushTimer.current = null
setPlayers(Array.from(playersRef.current.values()))
setGrenades(Array.from(grenadesRef.current.values()))
+ setTrails(Array.from(trailsRef.current.values()))
+ setDeathMarkers([...deathMarkersRef.current])
}, 66)
}
-
useEffect(() => {
return () => {
if (flushTimer.current != null) {
@@ -129,11 +207,21 @@ export default function LiveRadar() {
}
}, [])
- const metaUrl = buildWsUrl('CS2_META')
- const posUrl = buildWsUrl('CS2_POS')
+ // Runden-/Map-Reset
+ const clearRoundArtifacts = () => {
+ deathMarkersRef.current = []
+ trailsRef.current.clear()
+ grenadesRef.current.clear()
+ scheduleFlush()
+ }
+ useEffect(() => {
+ if (activeMapKey) clearRoundArtifacts()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeMapKey])
/* ───────── Meta-Callbacks ───────── */
const handleMetaMap = (key: string) => setActiveMapKey(key.toLowerCase())
+
const handleMetaPlayersSnapshot = (list: Array<{ steamId: string|number; name?: string; team?: any }>) => {
for (const p of list) {
const id = String(p.steamId ?? '')
@@ -146,10 +234,12 @@ export default function LiveRadar() {
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
yaw: old?.yaw ?? null,
alive: old?.alive,
+ hasBomb: old?.hasBomb ?? false,
})
}
scheduleFlush()
}
+
const handleMetaPlayerJoin = (p: any) => {
const id = String(p?.steamId ?? p?.id ?? p?.name ?? '')
if (!id) return
@@ -161,9 +251,11 @@ export default function LiveRadar() {
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
yaw: old?.yaw ?? null,
alive: true,
+ hasBomb: old?.hasBomb ?? false,
})
scheduleFlush()
}
+
const handleMetaPlayerLeave = (steamId: string | number) => {
const id = String(steamId)
const old = playersRef.current.get(id)
@@ -174,6 +266,10 @@ export default function LiveRadar() {
}
/* ───────── Positions-Callbacks ───────── */
+ const addDeathMarker = (x:number, y:number, idHint?: string) => {
+ deathMarkersRef.current.push({ id: idHint ?? `d#${Date.now()}`, x, y, t: Date.now() })
+ }
+
const upsertPlayer = (e: any) => {
const id = String(e.steamId ?? e.steam_id ?? e.userId ?? e.playerId ?? e.id ?? e.name ?? '')
if (!id) return
@@ -194,18 +290,28 @@ export default function LiveRadar() {
)
const old = playersRef.current.get(id)
+ const nextAlive = (e.alive !== undefined) ? !!e.alive : old?.alive
+ const hasBomb = detectHasBomb(e) || old?.hasBomb
+
+ // Alive→Dead → Death-X an aktueller Position speichern
+ if (old?.alive !== false && nextAlive === false) addDeathMarker(x, y, id)
+
playersRef.current.set(id, {
id,
name: e.name ?? old?.name ?? null,
team: mapTeam(e.team ?? old?.team),
x, y, z,
yaw: Number.isFinite(yaw) ? yaw : old?.yaw ?? null,
- alive: e.alive ?? old?.alive,
+ alive: nextAlive,
+ hasBomb: !!hasBomb,
})
}
+
const handlePlayersAll = (msg: any) => {
const ap = msg?.allplayers
if (!ap || typeof ap !== 'object') return
+
+ let total = 0, aliveCount = 0
for (const key of Object.keys(ap)) {
const p = ap[key]
const pos = parseVec3String(p.position)
@@ -213,16 +319,33 @@ export default function LiveRadar() {
const yaw = normalizeDeg(Math.atan2(fwd.y, fwd.x) * RAD2DEG)
const id = String(key)
const old = playersRef.current.get(id)
+ const isAlive = p.state?.health > 0 || p.state?.health == null
+ const hasBomb = detectHasBomb(p) || old?.hasBomb
+
+ if ((old?.alive ?? true) && !isAlive) addDeathMarker(pos.x, pos.y, id)
+
playersRef.current.set(id, {
id,
name: p.name ?? old?.name ?? null,
team: mapTeam(p.team ?? old?.team),
x: pos.x, y: pos.y, z: pos.z,
yaw,
- alive: p.state?.health > 0 || p.state?.health == null ? true : false,
+ alive: isAlive,
+ hasBomb: !!hasBomb,
})
+
+ total++
+ if (isAlive) aliveCount++
}
+
+ // Heuristik: Neue Runde → alles leeren
+ if (total > 0 && aliveCount === total && (deathMarkersRef.current.length > 0 || trailsRef.current.size > 0 || grenadesRef.current.size > 0)) {
+ clearRoundArtifacts()
+ }
+
+ scheduleFlush()
}
+
const normalizeGrenades = (raw: any): Grenade[] => {
if (!raw) return []
const pickTeam = (t: any): 'T' | 'CT' | string | null => {
@@ -291,14 +414,41 @@ export default function LiveRadar() {
}
return out
}
+
const handleGrenades = (g: any) => {
const list = normalizeGrenades(g)
+
+ // Trails updaten
+ const seen = new Set()
+ const now = Date.now()
+ for (const it of list) {
+ seen.add(it.id)
+ const prev = trailsRef.current.get(it.id) ?? { id: it.id, kind: it.kind, pts: [], lastSeen: 0 }
+ const last = prev.pts[prev.pts.length - 1]
+ if (!last || Math.hypot(it.x - last.x, it.y - last.y) > 1) {
+ prev.pts.push({ x: it.x, y: it.y })
+ if (prev.pts.length > UI.trail.maxPoints) prev.pts = prev.pts.slice(-UI.trail.maxPoints)
+ }
+ prev.kind = it.kind
+ prev.lastSeen = now
+ trailsRef.current.set(it.id, prev)
+ }
+ // Nicht mehr gesehene Trails ausdĂĽnnen
+ for (const [id, tr] of trailsRef.current) {
+ if (!seen.has(id) && now - tr.lastSeen > UI.trail.fadeMs) {
+ trailsRef.current.delete(id)
+ }
+ }
+
+ // aktuelle Nades ĂĽbernehmen
const next = new Map()
for (const it of list) next.set(it.id, it)
grenadesRef.current = next
+
+ scheduleFlush()
}
- // gemeinsamer flush bei Positionsdaten
+ // erster Flush
useEffect(() => {
if (!playersRef.current && !grenadesRef.current) return
scheduleFlush()
@@ -515,7 +665,7 @@ export default function LiveRadar() {
{currentSrc ? (
<>
@@ -524,7 +674,6 @@ export default function LiveRadar() {
src={currentSrc}
alt={activeMapKey}
className="block h-auto max-w-full"
- style={{ maxHeight: maxImgHeight ?? undefined }}
onLoad={(e) => {
const img = e.currentTarget
setImgSize({ w: img.naturalWidth, h: img.naturalHeight })
@@ -540,6 +689,26 @@ export default function LiveRadar() {
viewBox={`0 0 ${imgSize.w} ${imgSize.h}`}
preserveAspectRatio="xMidYMid meet"
>
+ {/* Trails */}
+ {trails.map(tr => {
+ const pts = tr.pts.map(p => {
+ const q = worldToPx(p.x, p.y)
+ return `${q.x},${q.y}`
+ }).join(' ')
+ if (!pts) return null
+ return (
+
+ )
+ })}
+
{/* Grenades */}
{grenades.map((g) => {
const P = worldToPx(g.x, g.y)
@@ -578,15 +747,15 @@ export default function LiveRadar() {
return
})}
- {/* Spieler */}
+ {/* Spieler (nur lebende anzeigen; Tote werden als X separat gezeichnet) */}
{players
- .filter(p => p.team === 'CT' || p.team === 'T')
+ .filter(p => (p.team === 'CT' || p.team === 'T') && p.alive !== false)
.map((p) => {
const A = worldToPx(p.x, p.y)
const base = Math.min(imgSize.w, imgSize.h)
const r = Math.max(UI.player.minRadiusPx, base * UI.player.radiusRel)
const dirLenPx = Math.max(UI.player.dirMinLenPx, r * UI.player.dirLenRel)
- const stroke = UI.player.stroke
+ const stroke = p.hasBomb ? UI.player.bombStroke : UI.player.stroke
const strokeW = Math.max(1, r * UI.player.lineWidthRel)
const fillColor = p.team === 'CT' ? UI.player.fillCT : UI.player.fillT
const dirColor = UI.player.dirColor === 'auto' ? contrastStroke(fillColor) : UI.player.dirColor
@@ -612,18 +781,30 @@ export default function LiveRadar() {
cx={A.x} cy={A.y} r={r}
fill={fillColor} stroke={stroke}
strokeWidth={Math.max(1, r*0.3)}
- opacity={p.alive === false ? 0.6 : 1}
/>
{Number.isFinite(p.yaw as number) && (
)}
)
})}
+
+ {/* Death-Marker (graues X an Todesposition) */}
+ {deathMarkers.map(dm => {
+ const P = worldToPx(dm.x, dm.y)
+ const s = UI.death.sizePx
+ return (
+
+
+
+
+ )
+ })}
)}
>
diff --git a/src/app/components/radar/MetaSocket.tsx b/src/app/components/radar/MetaSocket.tsx
index 5d0e340..915db3d 100644
--- a/src/app/components/radar/MetaSocket.tsx
+++ b/src/app/components/radar/MetaSocket.tsx
@@ -1,10 +1,7 @@
-// /app/components/MetaSocket.tsx
'use client'
-
import { useEffect, useRef } from 'react'
type Status = 'idle'|'connecting'|'open'|'closed'|'error'
-
type MetaSocketProps = {
url?: string
onStatus?: (s: Status) => void
@@ -26,45 +23,59 @@ export default function MetaSocket({
const aliveRef = useRef(true)
const retryRef = useRef
(null)
+ // aktuelle Handler in Refs spiegeln (ändern NICHT die Effect-Dependencies)
+ const onMapRef = useRef(onMap)
+ const onPlayersSnapshotRef = useRef(onPlayersSnapshot)
+ const onPlayerJoinRef = useRef(onPlayerJoin)
+ const onPlayerLeaveRef = useRef(onPlayerLeave)
+ useEffect(() => { onMapRef.current = onMap }, [onMap])
+ useEffect(() => { onPlayersSnapshotRef.current = onPlayersSnapshot }, [onPlayersSnapshot])
+ useEffect(() => { onPlayerJoinRef.current = onPlayerJoin }, [onPlayerJoin])
+ useEffect(() => { onPlayerLeaveRef.current = onPlayerLeave }, [onPlayerLeave])
+
useEffect(() => {
aliveRef.current = true
+
const connect = () => {
- if (!aliveRef.current) return
+ if (!aliveRef.current || !url) return
onStatus?.('connecting')
- const ws = new WebSocket(url!)
+
+ const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => onStatus?.('open')
ws.onerror = () => onStatus?.('error')
ws.onclose = () => {
onStatus?.('closed')
+ // optional: Backoff oder ganz ohne Auto-Reconnect, je nach Wunsch
if (aliveRef.current) retryRef.current = window.setTimeout(connect, 2000)
}
+
ws.onmessage = (ev) => {
let msg: any = null
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
if (!msg) return
- // KEINE matchId-Filterung mehr
if (msg.type === 'map' && typeof msg.name === 'string') {
- onMap?.(msg.name.toLowerCase())
+ onMapRef.current?.(msg.name.toLowerCase())
} else if (msg.type === 'players' && Array.isArray(msg.players)) {
- onPlayersSnapshot?.(msg.players)
+ onPlayersSnapshotRef.current?.(msg.players)
} else if (msg.type === 'player_join' && msg.player) {
- onPlayerJoin?.(msg.player)
+ onPlayerJoinRef.current?.(msg.player)
} else if (msg.type === 'player_leave') {
- onPlayerLeave?.(msg.steamId ?? msg.steam_id ?? msg.id)
+ onPlayerLeaveRef.current?.(msg.steamId ?? msg.steam_id ?? msg.id)
}
}
}
- if (url) connect()
+ connect()
+
return () => {
aliveRef.current = false
if (retryRef.current) window.clearTimeout(retryRef.current)
try { wsRef.current?.close(1000, 'meta unmounted') } catch {}
}
- }, [url, onStatus, onMap, onPlayersSnapshot, onPlayerJoin, onPlayerLeave])
+ }, [url, onStatus]) // <— nur auf url/onStatus hören!
return null
}
diff --git a/src/app/components/radar/PositionsSocket.tsx b/src/app/components/radar/PositionsSocket.tsx
index a1dbddb..0f5eadc 100644
--- a/src/app/components/radar/PositionsSocket.tsx
+++ b/src/app/components/radar/PositionsSocket.tsx
@@ -1,4 +1,3 @@
-// /app/components/PositionsSocket.tsx
'use client'
import { useEffect, useRef } from 'react'
@@ -12,6 +11,8 @@ type PositionsSocketProps = {
onPlayerUpdate?: (p: any) => void
onPlayersAll?: (allplayers: any) => void
onGrenades?: (g: any) => void
+ onRoundStart?: () => void // ⬅️ NEU
+ onRoundEnd?: () => void // ⬅️ optional
}
export default function PositionsSocket({
@@ -21,6 +22,8 @@ export default function PositionsSocket({
onPlayerUpdate,
onPlayersAll,
onGrenades,
+ onRoundStart,
+ onRoundEnd,
}: PositionsSocketProps) {
const wsRef = useRef(null)
const aliveRef = useRef(true)
@@ -29,7 +32,11 @@ export default function PositionsSocket({
const dispatch = (msg: any) => {
if (!msg) return
- // KEINE matchId-Filterung mehr
+ // Runde:
+ if (msg.type === 'round_start') { onRoundStart?.(); return }
+ if (msg.type === 'round_end') { onRoundEnd?.(); return }
+
+ // Tick (Fast-Path)
if (msg.type === 'tick') {
if (typeof msg.map === 'string') onMap?.(msg.map.toLowerCase())
if (Array.isArray(msg.players)) msg.players.forEach(onPlayerUpdate ?? (() => {}))
diff --git a/src/generated/prisma/edge.js b/src/generated/prisma/edge.js
index e755d8a..0d7ea2f 100644
--- a/src/generated/prisma/edge.js
+++ b/src/generated/prisma/edge.js
@@ -354,7 +354,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
- "value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
+ "value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null
},
"config": {
@@ -368,7 +368,7 @@ const config = {
}
],
"previewFeatures": [],
- "sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
+ "sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {
diff --git a/src/generated/prisma/index.js b/src/generated/prisma/index.js
index d6397cb..888bf8d 100644
--- a/src/generated/prisma/index.js
+++ b/src/generated/prisma/index.js
@@ -355,7 +355,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
- "value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
+ "value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null
},
"config": {
@@ -369,7 +369,7 @@ const config = {
}
],
"previewFeatures": [],
- "sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
+ "sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {