diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts new file mode 100644 index 0000000..0d4ba7b --- /dev/null +++ b/app/api/dashboard/route.ts @@ -0,0 +1,69 @@ +// app/api/dashboard/route.ts +// API endpoint for dashboard data + +import { query } from '@/lib/db'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const sql = ` + SELECT + u.driver_guid, + u.driver_name, + u.driver_team, + u.car_model, + u.car_skin, + u.user_rank, + u.laps_completed, + s.server_id, + s.server_name, + s.server_track, + s.server_config, + s.session_type, + s.session_flag, + s.session_time, + s.session_laps, + s.session_elapsed_time, + s.session_ambient_temp, + s.session_road_temp, + s.connected_players + FROM users u + INNER JOIN servers s ON u.current_server = s.server_id + WHERE u.is_connect = true + ORDER BY s.server_id, u.user_rank ASC + `; + + const rows = await query(sql); + + const drivers = rows.map((row: any) => ({ + driver_guid: row.driver_guid, + driver_name: row.driver_name, + driver_team: row.driver_team, + car_model: row.car_model, + car_skin: row.car_skin, + laps_completed: row.laps_completed, + user_rank: row.user_rank, + server: { + server_id: row.server_id, + server_name: row.server_name, + server_track: row.server_track, + server_config: row.server_config, + session_type: row.session_type, + session_flag: row.session_flag, + session_time: row.session_time, + session_laps: row.session_laps, + session_elapsed_time: row.session_elapsed_time, + session_ambient_temp: row.session_ambient_temp, + session_road_temp: row.session_road_temp, + connected_players: row.connected_players, + }, + })); + + return NextResponse.json({ drivers }); + } catch (error) { + console.error('[Dashboard API] Error:', error); + return NextResponse.json({ error: 'Failed to fetch dashboard data' }, { status: 500 }); + } +} diff --git a/app/api/events/[id]/results/route.ts b/app/api/events/[id]/results/route.ts new file mode 100644 index 0000000..56b0c2c --- /dev/null +++ b/app/api/events/[id]/results/route.ts @@ -0,0 +1,45 @@ +// app/api/events/[id]/results/route.ts +import { query } from '@/lib/db'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + request: Request, + { params }: { params: { id: string } } +) { + try { + const standingsSql = ` + SELECT + tcs.team_id, + t.name as team_name, + tcs.total_points, + tcs.races_participated, + tcs.best_finish, + json_agg( + json_build_object( + 'driver_guid', u.driver_guid, + 'driver_name', u.driver_name, + 'position', er.position, + 'points_awarded', er.points_awarded, + 'laps_completed', er.laps_completed, + 'dnf', er.dnf + ) ORDER BY er.position ASC + ) as drivers + FROM team_championship_standings tcs + JOIN teams t ON tcs.team_id = t.id + LEFT JOIN event_results er ON tcs.event_id = er.event_id AND tcs.team_id = er.team_id + LEFT JOIN users u ON er.driver_guid = u.driver_guid + WHERE tcs.event_id = $1 + GROUP BY tcs.team_id, t.name, tcs.total_points, tcs.races_participated, tcs.best_finish + ORDER BY tcs.total_points DESC, tcs.best_finish ASC + `; + + const standings = await query(standingsSql, [params.id]); + + return NextResponse.json({ standings }); + } catch (error) { + console.error('[Event Results API] Error:', error); + return NextResponse.json({ error: 'Failed to fetch results' }, { status: 500 }); + } +} diff --git a/app/api/live/telemetry/route.ts b/app/api/live/telemetry/route.ts new file mode 100644 index 0000000..05feda4 --- /dev/null +++ b/app/api/live/telemetry/route.ts @@ -0,0 +1,142 @@ +// app/api/live/telemetry/route.ts +// Server-Sent Events endpoint streaming from C++ Unix socket + +import { getTelemetryBridge } from '@/lib/telemetryBridge'; +import { query } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +// Cache for driver ranks (refreshed periodically) +let rankCache: Map = new Map(); +let lastRankUpdate = 0; +const RANK_CACHE_TTL = 60000; // 1 minute + +async function updateRankCache() { + const now = Date.now(); + if (now - lastRankUpdate < RANK_CACHE_TTL) { + return; // Cache still valid + } + + try { + // Get all users sorted by rank (higher is better) + const users = await query( + `SELECT + driver_guid, + user_rank, + ROW_NUMBER() OVER (ORDER BY user_rank DESC) as rank_position + FROM users + WHERE user_rank IS NOT NULL + ORDER BY user_rank DESC` + ); + + rankCache.clear(); + users.forEach((row: any) => { + rankCache.set(row.driver_guid.toString(), { + rank: row.rank_position, + rating: row.user_rank + }); + }); + + lastRankUpdate = now; + console.log('[Rank Cache] Updated with', rankCache.size, 'drivers'); + } catch (error) { + console.error('[Rank Cache] Failed to update:', error); + } +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const serverId = searchParams.get('serverId'); + const filterServerId = serverId ? parseInt(serverId) : null; + + const encoder = new TextEncoder(); + const bridge = getTelemetryBridge(); + + const stream = new ReadableStream({ + async start(controller) { + console.log('[SSE] Client connected', filterServerId ? `(server ${filterServerId})` : '(all servers)'); + + // Update rank cache on new connection + await updateRankCache(); + + // Send initial connection message + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + type: 'connected', + bridge_status: bridge.isConnected() ? 'connected' : 'connecting', + timestamp: Date.now() + })}\n\n`)); + + // Subscribe to telemetry updates + const unsubscribe = bridge.subscribe((packet) => { + // Filter by server if specified + if (filterServerId && packet.server_id !== filterServerId) { + return; + } + + // Transform to client format + const telemetry = { + type: 'update', + timestamp: Date.now(), + server_id: packet.server_id, + cars: packet.cars.map((car) => { + const driverRank = rankCache.get(car.driver_guid); + return { + carID: car.carID, + driver_guid: car.driver_guid, + driver_name: car.driver_name, + car_model: car.car_model, + position: car.position_rank, + current_lap: car.current_lap, + normalizedSplinePos: car.normalizedSplinePos, + speed: Math.round(car.speed_kmh), + gear: car.gear, + rpm: car.rpm, + last_lap_time: car.last_lap_time || null, + best_lap_time: car.best_lap_time || null, + world_position: car.position, + user_rank: driverRank ? driverRank.rank : null, + user_rating: driverRank ? driverRank.rating : null, + }; + }), + }; + + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(telemetry)}\n\n`)); + } catch (error) { + console.error('[SSE] Error sending data:', error); + } + }); + + // Heartbeat every 30 seconds to keep connection alive + const heartbeat = setInterval(() => { + try { + controller.enqueue(encoder.encode(`: heartbeat\n\n`)); + } catch (error) { + clearInterval(heartbeat); + } + }, 30000); + + // Cleanup on connection close + request.signal.addEventListener('abort', () => { + console.log('[SSE] Client disconnected'); + unsubscribe(); + clearInterval(heartbeat); + try { + controller.close(); + } catch (error) { + // Controller already closed + } + }); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', // Disable nginx buffering + }, + }); +} diff --git a/app/api/telemetry/route.ts b/app/api/telemetry/route.ts deleted file mode 100644 index 9b8a996..0000000 --- a/app/api/telemetry/route.ts +++ /dev/null @@ -1,89 +0,0 @@ -// app/api/live/telemetry/route.ts -// Server-Sent Events endpoint streaming from C++ Unix socket - -import { getTelemetryBridge } from '@/lib/telemetryBridge'; - -export const dynamic = 'force-dynamic'; -export const runtime = 'nodejs'; - -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const serverId = searchParams.get('serverId'); - const filterServerId = serverId ? parseInt(serverId) : null; - - const encoder = new TextEncoder(); - const bridge = getTelemetryBridge(); - - const stream = new ReadableStream({ - start(controller) { - console.log('[SSE] Client connected', filterServerId ? `(server ${filterServerId})` : '(all servers)'); - - // Send initial connection message - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ - type: 'connected', - bridge_status: bridge.isConnected() ? 'connected' : 'connecting' - })}\n\n`)); - - // Subscribe to telemetry updates - const unsubscribe = bridge.subscribe((packet) => { - // Filter by server if specified - if (filterServerId && packet.server_id !== filterServerId) { - return; - } - - // Transform to client format - const telemetry = { - type: 'update', - timestamp: Date.now(), - server_id: packet.server_id, - cars: packet.cars.map((car, index) => ({ - carID: car.carID, - driver_guid: car.driver_guid, - driver_name: car.driver_name, - car_model: car.car_model, - position: car.position || index + 1, - current_lap: car.current_lap, - normalizedSplinePos: car.normalizedSplinePos, - speed: Math.round(car.speed_kmh), - gear: car.gear, - rpm: car.rpm, - last_lap_time: car.last_lap_time || null, - best_lap_time: car.best_lap_time || null, - })), - }; - - try { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(telemetry)}\n\n`)); - } catch (error) { - console.error('[SSE] Error sending data:', error); - } - }); - - // Heartbeat every 30 seconds - const heartbeat = setInterval(() => { - try { - controller.enqueue(encoder.encode(`: heartbeat\n\n`)); - } catch (error) { - clearInterval(heartbeat); - } - }, 30000); - - // Cleanup on connection close - request.signal.addEventListener('abort', () => { - console.log('[SSE] Client disconnected'); - unsubscribe(); - clearInterval(heartbeat); - controller.close(); - }); - }, - }); - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no', // Disable nginx buffering - }, - }); -} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 186a124..32489ce 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,13 +1,12 @@ // app/dashboard/page.tsx -// Dashboard - navbar/footer now in layout.tsx +// Dashboard - now with auto-refresh! import { query } from '@/lib/db'; -import { DriverWithServer } from '@/types/racing'; -import { UsersIcon, ServerIcon, ActivityIcon, MapPinIcon, FlagIcon, LiveDotIcon } from '@/components/ui/icons'; -import { cleanTrackName, cleanTrackConfig } from '@/lib/trackUtils'; +import { LiveDotIcon } from '@/components/ui/icons'; +import DashboardClient from '@/components/dashboard/DashboardClient'; export const dynamic = "force-dynamic"; -async function getConnectedDrivers(): Promise { +async function getConnectedDrivers() { const sql = ` SELECT u.driver_guid, @@ -20,7 +19,14 @@ async function getConnectedDrivers(): Promise { s.server_id, s.server_name, s.server_track, + s.server_config, + s.session_type, s.session_flag, + s.session_time, + s.session_laps, + s.session_elapsed_time, + s.session_ambient_temp, + s.session_road_temp, s.connected_players FROM users u INNER JOIN servers s ON u.current_server = s.server_id @@ -36,36 +42,27 @@ async function getConnectedDrivers(): Promise { driver_team: row.driver_team, car_model: row.car_model, car_skin: row.car_skin, - cuts_alltime: 0, - contacts_alltime: 0, laps_completed: row.laps_completed, user_rank: row.user_rank, - is_connect: true, - is_loading: false, - current_server: row.server_id, - created_at: new Date(), server: { server_id: row.server_id, server_name: row.server_name, server_track: row.server_track, - session_type: 0, + server_config: row.server_config, + session_type: row.session_type, session_flag: row.session_flag, + session_time: row.session_time, + session_laps: row.session_laps, + session_elapsed_time: row.session_elapsed_time, + session_ambient_temp: row.session_ambient_temp, + session_road_temp: row.session_road_temp, connected_players: row.connected_players, }, })); } export default async function DashboardPage() { - const drivers = await getConnectedDrivers(); - - const serverGroups = drivers.reduce((acc, driver) => { - const serverId = driver.server?.server_id ?? 0; - if (!acc[serverId]) { - acc[serverId] = []; - } - acc[serverId].push(driver); - return acc; - }, {} as Record); + const initialDrivers = await getConnectedDrivers(); return ( <> @@ -85,153 +82,10 @@ export default async function DashboardPage() {

- {/* Stats Grid */} -
- } - /> - } - /> - sum + d.laps_completed, 0)} - icon={} - /> -
+ {/* Client component with auto-refresh */} + - - {/* Server Listings */} -
- {Object.keys(serverGroups).length === 0 ? ( -
-
-

NO ACTIVE SESSIONS

-

System idle — waiting for connections

-
- ) : ( - Object.entries(serverGroups).map(([serverId, serverDrivers]) => { - const server = serverDrivers[0].server; - return ( -
- {/* Server Header */} -
-
-
-
-
- - LIVE -
- - ID: {server?.server_id} - -
-

- {server?.server_name} -

-
-
- - {server?.server_track} -
-
- - {server?.session_flag} -
-
- - {server?.connected_players} CONNECTED -
-
-
-
-
- - {/* Driver Table */} -
- - - - - - - - - - - - - {serverDrivers.map((driver, index) => ( - - - - - - - - - ))} - -
POSDRIVERTEAMCARRANKLAPS
- - {String(index + 1).padStart(2, '0')} - - - {driver.driver_name} - - - {driver.driver_team || '—'} - - - - {driver.car_model} - - - - {driver.user_rank} - - - {driver.laps_completed} -
-
-
- ); - }) - )} -
); } - -function StatCard({ - title, - value, - icon -}: { - title: string; - value: number; - icon: React.ReactNode; -}) { - return ( -
-
- {title} -
- {icon} -
-
-
- {value.toLocaleString()} -
-
- ); -} diff --git a/app/events/[event_id]/results/page.tsx b/app/events/[event_id]/results/page.tsx new file mode 100644 index 0000000..a25f954 --- /dev/null +++ b/app/events/[event_id]/results/page.tsx @@ -0,0 +1,223 @@ +// app/events/[id]/results/page.tsx +// Event championship results page with auto-refresh + +import { query } from '@/lib/db'; +import { TrophyIcon } from '@/components/ui/icons'; +import Link from 'next/link'; +import EventResultsClient from '@/components/events/EventResultsClient'; + +export const dynamic = "force-dynamic"; + +interface TeamStanding { + team_id: number; + team_name: string; + total_points: number; + races_participated: number; + best_finish: number; + drivers: { + driver_guid: string; + driver_name: string; + position: number; + points_awarded: number; + }[]; +} + +async function getEventResults(eventId: string) { + // Get event info + const eventSql = ` + SELECT + event_id, + event_name, + event_track, + event_date, + event_status + FROM events + WHERE event_id = $1 + `; + const events = await query(eventSql, [eventId]); + const event = events[0]; + + // Get team standings with driver details + const standingsSql = ` + SELECT + tcs.team_id, + t.name as team_name, + tcs.total_points, + tcs.races_participated, + tcs.best_finish, + json_agg( + json_build_object( + 'driver_guid', u.driver_guid, + 'driver_name', u.driver_name, + 'position', er.position, + 'points_awarded', er.points_awarded, + 'laps_completed', er.laps_completed, + 'dnf', er.dnf + ) ORDER BY er.position ASC + ) as drivers + FROM team_championship_standings tcs + JOIN teams t ON tcs.team_id = t.id + LEFT JOIN event_results er ON tcs.event_id = er.event_id AND tcs.team_id = er.team_id + LEFT JOIN users u ON er.driver_guid = u.driver_guid + WHERE tcs.event_id = $1 + GROUP BY tcs.team_id, t.name, tcs.total_points, tcs.races_participated, tcs.best_finish + ORDER BY tcs.total_points DESC, tcs.best_finish ASC + `; + + const standings = await query(standingsSql, [eventId]); + + return { event, standings }; +} + +function getPositionColor(position: number): string { + if (position === 1) return 'bg-yellow-500/20 border-yellow-500/50 text-yellow-400'; + if (position === 2) return 'bg-gray-400/20 border-gray-400/50 text-gray-300'; + if (position === 3) return 'bg-orange-600/20 border-orange-600/50 text-orange-400'; + return 'bg-white/5 border-white/10 text-white/60'; +} + +export default async function EventResultsPage({ params }: { params: { id: string } }) { + const { event, standings } = await getEventResults(params.id); + + if (!event) { + return ( +
+
+

Event not found

+
+
+ ); + } + + return ( + <> + {/* Hero */} +
+
+
+ + + Back to Event + + +
+ +
+

+ CHAMPIONSHIP RESULTS +

+

+ {event.event_name} +

+
+
+
+
+
+ + {/* Team Championship Standings */} +
+ {standings.length === 0 ? ( +
+ +

NO RESULTS YET

+

Results will appear after the event concludes

+
+ ) : ( +
+ {standings.map((team: TeamStanding, index: number) => ( +
+
+
+ {/* Position Badge */} +
+ + {index + 1} + +
+ + {/* Team Info */} +
+

+ {team.team_name} +

+
+
+ + {team.drivers.length} {team.drivers.length === 1 ? 'Driver' : 'Drivers'} +
+
+ + Best Finish: P{team.best_finish} +
+
+
+
+ + {/* Total Points */} +
+
+ {team.total_points} +
+
POINTS
+
+
+ + {/* Driver Results */} +
+
+ {team.drivers.map((driver: any) => ( +
+
+
+ P{driver.position} +
+
+
{driver.driver_name}
+ {driver.dnf && ( +
DNF
+ )} +
+
+
+
{driver.points_awarded}
+
pts
+
+
+ ))} +
+
+
+ ))} +
+ )} + + {/* Points System Info */} +
+

POINTS SYSTEM

+
+
P1: 25 pts
+
P2: 18 pts
+
P3: 15 pts
+
P4: 12 pts
+
P5: 10 pts
+
+

+ Team points are the sum of all driver points from that team in the event +

+
+
+ + ); +} diff --git a/components/dashboard/DashboardClient.tsx b/components/dashboard/DashboardClient.tsx new file mode 100644 index 0000000..4aaeabf --- /dev/null +++ b/components/dashboard/DashboardClient.tsx @@ -0,0 +1,265 @@ +// components/dashboard/DashboardClient.tsx +'use client'; + +import { useEffect, useState } from 'react'; +import { UsersIcon, ServerIcon, ActivityIcon, MapPinIcon, FlagIcon, LiveDotIcon, ClockIcon } from '@/components/ui/icons'; +import { cleanTrackName, cleanTrackConfig } from '@/lib/trackUtils'; + +interface Driver { + driver_guid: string; + driver_name: string; + driver_team: string; + car_model: string; + car_skin: string; + laps_completed: number; + user_rank: number; + server: { + server_id: number; + server_name: string; + server_track: string; + server_config: string; + session_type: number; + session_flag: string; + session_time: number; + session_laps: number; + session_elapsed_time: number; + session_ambient_temp: number; + session_road_temp: number; + connected_players: number; + }; +} + +function getSessionTypeName(type: number): string { + switch (type) { + case 0: return 'PRACTICE'; + case 1: return 'RACE'; + case 2: return 'QUALIFYING'; + default: return 'UNKNOWN'; + } +} + +function getSessionTypeColor(type: number): string { + switch (type) { + case 0: return 'border-blue-500/30 bg-blue-500/10 text-blue-400'; + case 1: return 'border-red-500/30 bg-red-500/10 text-red-400'; + case 2: return 'border-yellow-500/30 bg-yellow-500/10 text-yellow-400'; + default: return 'border-white/20 bg-white/5 text-white/60'; + } +} + +function formatElapsedTime(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +export default function DashboardClient({ initialDrivers }: { initialDrivers: Driver[] }) { + const [drivers, setDrivers] = useState(initialDrivers); + const [isLoading, setIsLoading] = useState(false); + + // Auto-refresh every 3 seconds + useEffect(() => { + const fetchData = async () => { + try { + setIsLoading(true); + const response = await fetch('/api/dashboard'); + const data = await response.json(); + setDrivers(data.drivers); + } catch (error) { + console.error('[Dashboard] Failed to fetch data:', error); + } finally { + setIsLoading(false); + } + }; + + const interval = setInterval(fetchData, 3000); + return () => clearInterval(interval); + }, []); + + // Group drivers by server + const serverGroups = drivers.reduce((acc, driver) => { + const serverId = driver.server?.server_id ?? 0; + if (!acc[serverId]) { + acc[serverId] = []; + } + acc[serverId].push(driver); + return acc; + }, {} as Record); + + return ( + <> + {/* Stats Grid */} +
+ } + pulse={isLoading} + /> + } + pulse={isLoading} + /> + sum + d.laps_completed, 0)} + icon={} + pulse={isLoading} + /> +
+ + {/* Server Listings */} +
+ {Object.keys(serverGroups).length === 0 ? ( +
+
+

NO ACTIVE SESSIONS

+

System idle — waiting for connections

+
+ ) : ( + Object.entries(serverGroups).map(([serverId, serverDrivers]) => { + const server = serverDrivers[0].server; + return ( +
+ {/* Server Header */} +
+
+
+
+
+ + LIVE +
+ + ID: {server?.server_id} + +
+

+ {server?.server_name} +

+
+
+ + + {cleanTrackName(server?.server_track || '')} + {server?.server_config && ` - ${cleanTrackConfig(server.server_config)}`} + +
+
+ + {server?.session_flag} +
+
+ + {server?.connected_players} CONNECTED +
+
+
+ + {/* Session Info Badge */} +
+
+ {getSessionTypeName(server?.session_type || 0)} +
+
+ + + {formatElapsedTime(server?.session_elapsed_time || 0)} + +
+
+
+
+ + {/* Driver Table */} +
+ + + + + + + + + + + + + {serverDrivers.map((driver, index) => ( + + + + + + + + + ))} + +
POSDRIVERTEAMCARRANKLAPS
+ + {String(index + 1).padStart(2, '0')} + + + {driver.driver_name} + + + {driver.driver_team || '—'} + + + + {driver.car_model} + + + + {driver.user_rank} + + + {driver.laps_completed} +
+
+
+ ); + }) + )} +
+ + ); +} + +function StatCard({ + title, + value, + icon, + pulse +}: { + title: string; + value: number; + icon: React.ReactNode; + pulse?: boolean; +}) { + return ( +
+
+ {title} +
+ {icon} +
+
+
+ {value.toLocaleString()} +
+
+ ); +} diff --git a/components/events/EventResultsClient.tsx b/components/events/EventResultsClient.tsx new file mode 100644 index 0000000..70ccbd4 --- /dev/null +++ b/components/events/EventResultsClient.tsx @@ -0,0 +1,144 @@ +// components/events/EventResultsClient.tsx +'use client'; + +import { useEffect, useState } from 'react'; +import { TrophyIcon, UsersIcon, FlagIcon } from '@/components/ui/icons'; + +interface TeamStanding { + team_id: number; + team_name: string; + total_points: number; + races_participated: number; + best_finish: number; + drivers: { + driver_guid: string; + driver_name: string; + position: number; + points_awarded: number; + dnf: boolean; + }[]; +} + +function getPositionColor(position: number): string { + if (position === 1) return 'bg-yellow-500/20 border-yellow-500/50 text-yellow-400'; + if (position === 2) return 'bg-gray-400/20 border-gray-400/50 text-gray-300'; + if (position === 3) return 'bg-orange-600/20 border-orange-600/50 text-orange-400'; + return 'bg-white/5 border-white/10 text-white/60'; +} + +export default function EventResultsClient({ + eventId, + initialStandings +}: { + eventId: string; + initialStandings: TeamStanding[] +}) { + const [standings, setStandings] = useState(initialStandings); + const [isLoading, setIsLoading] = useState(false); + + // Auto-refresh every 5 seconds + useEffect(() => { + const fetchResults = async () => { + try { + setIsLoading(true); + const response = await fetch(`/api/events/${eventId}/results`); + const data = await response.json(); + setStandings(data.standings); + } catch (error) { + console.error('[Results] Failed to fetch:', error); + } finally { + setIsLoading(false); + } + }; + + const interval = setInterval(fetchResults, 5000); + return () => clearInterval(interval); + }, [eventId]); + + if (standings.length === 0) { + return ( +
+ +

NO RESULTS YET

+

Results will appear after the event concludes

+
+ ); + } + + return ( +
+ {standings.map((team: TeamStanding, index: number) => ( +
+
+
+ {/* Position Badge */} +
+ + {index + 1} + +
+ + {/* Team Info */} +
+

+ {team.team_name} +

+
+
+ + {team.drivers.length} {team.drivers.length === 1 ? 'Driver' : 'Drivers'} +
+
+ + Best Finish: P{team.best_finish} +
+
+
+
+ + {/* Total Points */} +
+
+ {team.total_points} +
+
POINTS
+
+
+ + {/* Driver Results */} +
+
+ {team.drivers.map((driver: any) => ( +
+
+
+ P{driver.position} +
+
+
{driver.driver_name}
+ {driver.dnf && ( +
DNF
+ )} +
+
+
+
{driver.points_awarded}
+
pts
+
+
+ ))} +
+
+
+ ))} +
+ ); +} diff --git a/components/live/LiveRefreshWrapper.tsx b/components/live/LiveRefreshWrapper.tsx index 6c6ddf8..ae97d41 100644 --- a/components/live/LiveRefreshWrapper.tsx +++ b/components/live/LiveRefreshWrapper.tsx @@ -8,10 +8,9 @@ export default function LiveRefreshWrapper({ children }: { children: React.React const router = useRouter(); useEffect(() => { - // Refresh every 3 seconds for live updates const interval = setInterval(() => { router.refresh(); - }, 3000); + }, 500); return () => clearInterval(interval); }, [router]); diff --git a/components/live/LiveTiming.tsx b/components/live/LiveTiming.tsx index 6cd276f..8f0fe56 100644 --- a/components/live/LiveTiming.tsx +++ b/components/live/LiveTiming.tsx @@ -1,7 +1,7 @@ // components/live/LiveTiming.tsx 'use client'; -import { BoltIcon } from '@/components/ui/icons'; +import { BoltIcon, TrophyIcon } from '@/components/ui/icons'; interface TimingEntry { position: number; @@ -13,6 +13,8 @@ interface TimingEntry { best_lap_time: number | null; gap_to_leader: string; avg_lap_time: number | null; + user_rank?: number | null; // Driver's position in rankings (1st, 2nd, etc.) + user_rating?: number | null; // Driver's rating score (ELO-style) } interface LiveTimingProps { @@ -23,7 +25,7 @@ export default function LiveTiming({ entries }: LiveTimingProps) { // Format lap time from milliseconds const formatLapTime = (ms: number | null) => { - if (!ms || ms === 0) return '-:--.---'; + if (!ms || ms === 0) return '--:--.---'; const minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); @@ -32,14 +34,36 @@ export default function LiveTiming({ entries }: LiveTimingProps) { return `${minutes}:${String(seconds).padStart(2, '0')}.${String(milliseconds).padStart(3, '0')}`; }; + // Format position with fallback + const formatPosition = (position: number | null | undefined) => { + if (!position || position === 0) return '--'; + return String(position).padStart(2, '0'); + }; + + // Format lap count with fallback + const formatLapCount = (laps: number | null | undefined) => { + if (!laps || laps === 0) return '--'; + return laps; + }; + // Get position color const getPositionColor = (position: number) => { + if (!position || position === 0) return 'text-white/40 border-white/10'; if (position === 1) return 'text-white border-white'; if (position === 2) return 'text-white/90 border-white/70'; if (position === 3) return 'text-white/80 border-white/50'; return 'text-white/60 border-white/20'; }; + // Get rank badge color + const getRankColor = (rank: number | null | undefined) => { + if (!rank) return 'bg-white/10 text-white/40'; + if (rank <= 10) return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'; + if (rank <= 50) return 'bg-blue-500/20 text-blue-400 border-blue-500/30'; + if (rank <= 100) return 'bg-green-500/20 text-green-400 border-green-500/30'; + return 'bg-white/10 text-white/60 border-white/20'; + }; + return (
{/* Header */} @@ -56,7 +80,8 @@ export default function LiveTiming({ entries }: LiveTimingProps) { {entries.map((entry) => { const isLeader = entry.position === 1; const isFastestLap = entries.length > 0 && - entry.best_lap_time === Math.min(...entries.filter(e => e.best_lap_time).map(e => e.best_lap_time!)); + entry.best_lap_time && entry.best_lap_time > 0 && + entry.best_lap_time === Math.min(...entries.filter(e => e.best_lap_time && e.best_lap_time > 0).map(e => e.best_lap_time!)); return (
- {String(entry.position).padStart(2, '0')} + {formatPosition(entry.position)}
{/* Driver Info */}
-
{entry.driver_name}
+
+ + {entry.driver_name} + + {entry.user_rank && ( +
+ + #{entry.user_rank} + + {entry.user_rating && ( + + {entry.user_rating} + + )} +
+ )} +
{entry.car_model}
{/* Current Lap */}
- {entry.current_lap} + {formatLapCount(entry.current_lap)}
{/* Last Lap Time */} @@ -91,7 +132,7 @@ export default function LiveTiming({ entries }: LiveTimingProps) {
{formatLapTime(entry.last_lap_time)}
- {entry.avg_lap_time && ( + {entry.avg_lap_time && entry.avg_lap_time > 0 && (
Avg: {formatLapTime(entry.avg_lap_time)}
@@ -118,7 +159,14 @@ export default function LiveTiming({ entries }: LiveTimingProps) { Fastest lap overall
+
+ + #1-10 + + Top 10 ranked driver +
Times updated in real-time from server telemetry
+
-- indicates driver has not started or is in pits
); diff --git a/components/live/LiveTrackMap.tsx b/components/live/LiveTrackMap.tsx index 72152ac..efefd97 100644 --- a/components/live/LiveTrackMap.tsx +++ b/components/live/LiveTrackMap.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { getTrackMapUrl, cleanTrackName, cleanTrackConfig } from '@/lib/trackUtils'; +import { getTrackMapConfig, worldToMapCoords, type TrackMapConfig } from '@/lib/trackMapConfig'; interface Car { carID: number; @@ -12,6 +13,7 @@ interface Car { position: number; lap_time?: number; best_lap_time?: number; + world_position?: { x: number; y: number; z: number }; } interface LiveTrackMapProps { @@ -22,31 +24,79 @@ interface LiveTrackMapProps { export default function LiveTrackMap({ track, trackConfig, cars }: LiveTrackMapProps) { const [imageError, setImageError] = useState(false); + const [mapConfig, setMapConfig] = useState(null); - // Get cleaned track map URL + // Static track bounds - set once and never change + const [trackBounds, setTrackBounds] = useState<{ + minX: number; + maxX: number; + minZ: number; + maxZ: number; + } | null>(null); + + // Add mouse position state + const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null); + + // Define event handlers + const handleMouseMove = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 100; + const y = ((e.clientY - rect.top) / rect.height) * 100; + setMousePos({ x, y }); + }; + + const handleMouseLeave = () => { + setMousePos(null); + }; + const trackMapUrl = getTrackMapUrl(track, trackConfig); const displayTrackName = cleanTrackName(track); const displayTrackConfig = cleanTrackConfig(trackConfig); - // Calculate position on track (circular approximation) - const getCarPosition = (normalizedPos: number) => { - // normalizedPos is 0.0 to 1.0 around the track - // We'll place cars in a circular path for now - const angle = normalizedPos * Math.PI * 2 - Math.PI / 2; // Start at top - - // Position relative to center (percentage) - const centerX = 50; - const centerY = 50; - const radius = 40; // 40% from center - - const x = centerX + Math.cos(angle) * radius; - const y = centerY + Math.sin(angle) * radius; - - return { x, y }; + useEffect(() => { + getTrackMapConfig(track, trackConfig).then(config => { + setMapConfig(config); + if (config) { + const halfWidth = config.width / 2; + const halfHeight = config.height / 2; + setTrackBounds({ + minX: config.xOffset - halfWidth, + maxX: config.xOffset + halfWidth, + minZ: config.zOffset - halfHeight, + maxZ: config.zOffset + halfHeight, + }); + } + }); + }, [track, trackConfig]); + + // Convert world position to screen position + const getCarPosition = (car: Car) => { + // Use AC's formula with map.ini config + if (car.world_position && car.world_position.x !== undefined && mapConfig) { + const pos = worldToMapCoords( + car.world_position.x, + car.world_position.y, + car.world_position.z, + mapConfig + ); + + // Debug first car only + if (cars.indexOf(car) === 0) { + console.log(`[Map] World: (${car.world_position.x.toFixed(1)}, ${car.world_position.z.toFixed(1)}) -> Screen: (${pos.x.toFixed(1)}%, ${pos.y.toFixed(1)}%)`); + } + + return pos; + } + + // Fallback: distribute along diagonal based on normalizedSplinePos + const fallbackX = car.normalizedSplinePos * 100; + const fallbackY = car.normalizedSplinePos * 100; + return { x: fallbackX, y: fallbackY }; }; // Get color based on position const getPositionColor = (position: number) => { + if (!position || position === 0) return '#6b7280'; // Gray for no position if (position === 1) return '#ffffff'; // P1 - White if (position === 2) return '#d1d5db'; // P2 - Light gray if (position === 3) return '#9ca3af'; // P3 - Gray @@ -54,7 +104,11 @@ export default function LiveTrackMap({ track, trackConfig, cars }: LiveTrackMapP }; return ( -
+
{/* Track Map Background */} {!imageError ? ( {cars.map((car) => { - const pos = getCarPosition(car.normalizedSplinePos); + const pos = getCarPosition(car); const color = getPositionColor(car.position); - + return (
- + {/* Position number */} -
- P{car.position} -
+ {car.position > 0 && ( +
+ P{car.position} +
+ )} {/* Driver name on hover */}
diff --git a/lib/telemetryBridge.ts b/lib/telemetryBridge.ts index 7097bd7..34b08eb 100644 --- a/lib/telemetryBridge.ts +++ b/lib/telemetryBridge.ts @@ -1,15 +1,22 @@ // lib/telemetryBridge.ts -// Bridge between C++ Unix socket and Next.js SSE +// Bridge between C++ Unix socket telemetry server and Next.js SSE clients -import { Socket } from 'net'; +import net from 'net'; const TELEMETRY_SOCKET_PATH = '/tmp/ACtelemetry_socket'; +interface Position { + x: number; + y: number; + z: number; +} + interface CarTelemetry { carID: number; driver_name: string; driver_guid: string; car_model: string; + position: Position; normalizedSplinePos: number; speed_kmh: number; gear: number; @@ -17,7 +24,7 @@ interface CarTelemetry { last_lap_time: number; best_lap_time: number; current_lap: number; - position: number; + position_rank: number; } interface TelemetryPacket { @@ -26,13 +33,13 @@ interface TelemetryPacket { cars: CarTelemetry[]; } -type TelemetryCallback = (data: TelemetryPacket) => void; +type TelemetryCallback = (packet: TelemetryPacket) => void; class TelemetryBridge { - private socket: Socket | null = null; + private socket: net.Socket | null = null; + private subscribers: Set = new Set(); + private reconnectTimer: NodeJS.Timeout | null = null; private connected: boolean = false; - private callbacks: Set = new Set(); - private reconnectTimeout: NodeJS.Timeout | null = null; private buffer: Buffer = Buffer.alloc(0); constructor() { @@ -40,111 +47,146 @@ class TelemetryBridge { } private connect() { - console.log('[Telemetry] Connecting to', TELEMETRY_SOCKET_PATH); - - this.socket = new Socket(); - - this.socket.connect(TELEMETRY_SOCKET_PATH, () => { - console.log('[Telemetry] Connected to C++ socket'); + if (this.socket) { + this.socket.destroy(); + } + + console.log('[Bridge] Connecting to telemetry socket...'); + this.socket = net.createConnection(TELEMETRY_SOCKET_PATH); + + this.socket.on('connect', () => { + console.log('[Bridge] Connected to telemetry server'); this.connected = true; - this.buffer = Buffer.alloc(0); // Reset buffer on new connection + this.buffer = Buffer.alloc(0); + + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } }); this.socket.on('data', (data: Buffer) => { // Append new data to buffer this.buffer = Buffer.concat([this.buffer, data]); - - // Parse complete packets from buffer - this.parsePackets(); + + // Calculate expected packet size + // server_id (1) + car_count (1) + cars (car_count * car_size) + const HEADER_SIZE = 2; // server_id + car_count + const CAR_SIZE = 1 + 64 + 64 + 64 + 4 + 4 + 1 + 2 + 4 + 4 + 2 + 1; // 211 bytes per car + + while (this.buffer.length >= HEADER_SIZE) { + const server_id = this.buffer.readUInt8(0); + const car_count = this.buffer.readUInt8(1); + + // Updated CAR_SIZE: 1 + 64 + 64 + 64 + 12 + 4 + 4 + 1 + 2 + 4 + 4 + 2 + 1 = 227 bytes + const CAR_SIZE = 227; + const expected_size = HEADER_SIZE + (car_count * CAR_SIZE); + + if (this.buffer.length >= expected_size) { + // We have a complete packet + const packet_data = this.buffer.slice(0, expected_size); + this.buffer = this.buffer.slice(expected_size); + + // Parse packet + const packet = this.parsePacket(packet_data); + if (packet) { + // Broadcast to all subscribers + this.subscribers.forEach(callback => { + try { + callback(packet); + } catch (error) { + console.error('[Bridge] Error in subscriber callback:', error); + } + }); + } + } else { + // Wait for more data + break; + } + } }); this.socket.on('error', (err) => { - console.error('[Telemetry] Socket error:', err.message); + console.error('[Bridge] Socket error:', err.message); this.connected = false; }); this.socket.on('close', () => { - console.log('[Telemetry] Connection closed, reconnecting in 2s...'); + console.log('[Bridge] Connection closed, reconnecting in 5s...'); this.connected = false; - this.socket = null; - // Reconnect after 2 seconds - if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = setTimeout(() => this.connect(), 2000); + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + } + + this.reconnectTimer = setTimeout(() => { + this.connect(); + }, 5000); }); } - private parsePackets() { - // Telemetry packet structure from C++: - // uint8_t server_id (1 byte) - // uint8_t car_count (1 byte) - // car_telemetry cars[64] (each car = 158 bytes) - - const HEADER_SIZE = 2; - const CAR_SIZE = 158; // Size of car_telemetry struct - - while (this.buffer.length >= HEADER_SIZE) { - const server_id = this.buffer.readUInt8(0); - const car_count = this.buffer.readUInt8(1); - - const expected_size = HEADER_SIZE + (car_count * CAR_SIZE); - - if (this.buffer.length < expected_size) { - // Not enough data yet, wait for more - break; - } - - // Parse the packet - const packet: TelemetryPacket = { - server_id, - car_count, - cars: [], - }; - - let offset = HEADER_SIZE; - + private parsePacket(data: Buffer): TelemetryPacket | null { + try { + let offset = 0; + + const server_id = data.readUInt8(offset); + offset += 1; + + const car_count = data.readUInt8(offset); + offset += 1; + + const cars: CarTelemetry[] = []; + for (let i = 0; i < car_count; i++) { - const carID = this.buffer.readUInt8(offset); + const carID = data.readUInt8(offset); offset += 1; - - const driver_name = this.buffer.toString('utf8', offset, offset + 64).replace(/\0.*$/g, ''); + + const driver_name = this.readString(data, offset, 64); offset += 64; - - const driver_guid = this.buffer.toString('utf8', offset, offset + 64).replace(/\0.*$/g, ''); + + const driver_guid = this.readString(data, offset, 64); offset += 64; - - const car_model = this.buffer.toString('utf8', offset, offset + 64).replace(/\0.*$/g, ''); + + const car_model = this.readString(data, offset, 64); offset += 64; - - const normalizedSplinePos = this.buffer.readFloatLE(offset); + + const position: Position = { + x: data.readFloatLE(offset), + y: data.readFloatLE(offset + 4), + z: data.readFloatLE(offset + 8), + }; + offset += 12; + + const normalizedSplinePos = data.readFloatLE(offset); offset += 4; - - const speed_kmh = this.buffer.readFloatLE(offset); + + const speed_kmh = data.readFloatLE(offset); offset += 4; - - const gear = this.buffer.readUInt8(offset); + + const gear = data.readUInt8(offset); offset += 1; - - const rpm = this.buffer.readUInt16LE(offset); + + const rpm = data.readUInt16LE(offset); offset += 2; - - const last_lap_time = this.buffer.readUInt32LE(offset); + + const last_lap_time = data.readUInt32LE(offset); offset += 4; - - const best_lap_time = this.buffer.readUInt32LE(offset); + + const best_lap_time = data.readUInt32LE(offset); offset += 4; - - const current_lap = this.buffer.readUInt16LE(offset); + + const current_lap = data.readUInt16LE(offset); offset += 2; - - const position = this.buffer.readUInt8(offset); + + const position_rank = data.readUInt8(offset); offset += 1; - - packet.cars.push({ + + cars.push({ carID, driver_name, driver_guid, car_model, + position, normalizedSplinePos, speed_kmh, gear, @@ -152,51 +194,82 @@ class TelemetryBridge { last_lap_time, best_lap_time, current_lap, - position, + position_rank, }); } - - // Emit packet to all callbacks - this.callbacks.forEach(cb => cb(packet)); - - // Remove processed packet from buffer - this.buffer = this.buffer.subarray(expected_size); + + return { + server_id, + car_count, + cars, + }; + } catch (error) { + console.error('[Bridge] Error parsing packet:', error); + return null; } } + private readString(buffer: Buffer, offset: number, length: number): string { + const end = buffer.indexOf(0, offset); + const strEnd = end === -1 || end >= offset + length ? offset + length : end; + return buffer.toString('utf8', offset, strEnd).trim(); + } + public subscribe(callback: TelemetryCallback): () => void { - this.callbacks.add(callback); - console.log('[Telemetry] Subscriber added, total:', this.callbacks.size); - + this.subscribers.add(callback); + console.log('[Bridge] Subscriber added (total:', this.subscribers.size, ')'); + // Return unsubscribe function return () => { - this.callbacks.delete(callback); - console.log('[Telemetry] Subscriber removed, total:', this.callbacks.size); + this.subscribers.delete(callback); + console.log('[Bridge] Subscriber removed (total:', this.subscribers.size, ')'); }; } + public getSubscriberCount(): number { + return this.subscribers.size; + } + public isConnected(): boolean { return this.connected; } - public disconnect() { - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); + public destroy() { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; } + if (this.socket) { this.socket.destroy(); this.socket = null; } - this.connected = false; + + this.subscribers.clear(); } } // Singleton instance -let bridge: TelemetryBridge | null = null; +let bridgeInstance: TelemetryBridge | null = null; export function getTelemetryBridge(): TelemetryBridge { - if (!bridge) { - bridge = new TelemetryBridge(); + if (!bridgeInstance) { + bridgeInstance = new TelemetryBridge(); } - return bridge; + return bridgeInstance; +} + +// Cleanup on process exit +if (typeof process !== 'undefined') { + process.on('SIGTERM', () => { + if (bridgeInstance) { + bridgeInstance.destroy(); + } + }); + + process.on('SIGINT', () => { + if (bridgeInstance) { + bridgeInstance.destroy(); + } + }); } diff --git a/lib/trackMapConfig.ts b/lib/trackMapConfig.ts new file mode 100644 index 0000000..aa5334a --- /dev/null +++ b/lib/trackMapConfig.ts @@ -0,0 +1,174 @@ +// lib/trackMapConfig.ts +// Parse and use AC track map.ini configuration files + +export interface TrackMapConfig { + width: number; + height: number; + margin: number; + scaleFactor: number; + xOffset: number; + zOffset: number; + drawingSize: number; +} + +// Cache for parsed configs +const configCache = new Map(); + +/** + * Clean track name from database format (removes CSP prefix) + * Example: "csp/2100/../spa" -> "spa" + */ +export function cleanTrackPath(track: string): string { + // Remove CSP prefix pattern: csp/XXXX/../ + const cleaned = track.replace(/^csp\/\d+\/\.\.\//, ''); + return cleaned; +} + +/** + * Fetch and parse a track's map.ini file + */ +export async function getTrackMapConfig( + track: string, + trackConfig: string = '' +): Promise { + // Clean track name first + const cleanTrack = cleanTrackPath(track); + const cleanConfig = trackConfig || ''; + + const cacheKey = `${cleanTrack}|${cleanConfig}`; + + // Check cache first + if (configCache.has(cacheKey)) { + return configCache.get(cacheKey)!; + } + + try { + // Try to fetch from openwheels.racing first + let configUrl = cleanConfig && cleanConfig !== 'default' + ? `https://openwheels.racing/files/img/tracks/${cleanTrack}/${cleanConfig}/map.ini` + : `https://openwheels.racing/files/img/tracks/${cleanTrack}/map.ini`; + + let response = await fetch(configUrl); + + // Fallback to local public directory + if (!response.ok) { + const localPath = cleanConfig && cleanConfig !== 'default' + ? `/tracks/${cleanTrack}/${cleanConfig}/map.ini` + : `/tracks/${cleanTrack}/map.ini`; + + response = await fetch(localPath); + } + + if (!response.ok) { + console.log(`[TrackMap] No map.ini found for ${cleanTrack}/${cleanConfig}`); + configCache.set(cacheKey, null); + return null; + } + + const iniText = await response.text(); + const config = parseMapIni(iniText); + + console.log(`[TrackMap] Loaded config for ${cleanTrack}/${cleanConfig}:`, config); + configCache.set(cacheKey, config); + return config; + } catch (error) { + console.warn(`[TrackMap] Failed to load map.ini for ${cleanTrack}:`, error); + configCache.set(cacheKey, null); + return null; + } +} + +/** + * Parse map.ini text format + */ +function parseMapIni(iniText: string): TrackMapConfig { + const lines = iniText.split('\n'); + const config: Partial = {}; + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip comments and empty lines + if (!trimmed || trimmed.startsWith(';') || trimmed.startsWith('[')) { + continue; + } + + // Parse KEY=VALUE + const [key, value] = trimmed.split('=').map(s => s.trim()); + if (!key || !value) continue; + + const numValue = parseFloat(value); + + switch (key.toUpperCase()) { + case 'WIDTH': + config.width = numValue; + break; + case 'HEIGHT': + config.height = numValue; + break; + case 'MARGIN': + config.margin = numValue; + break; + case 'SCALE_FACTOR': + config.scaleFactor = numValue; + break; + case 'X_OFFSET': + config.xOffset = numValue; + break; + case 'Z_OFFSET': + config.zOffset = numValue; + break; + case 'DRAWING_SIZE': + config.drawingSize = numValue; + break; + } + } + + // Return with defaults if any values are missing + return { + width: config.width || 1000, + height: config.height || 1000, + margin: config.margin || 20, + scaleFactor: config.scaleFactor || 1.0, + xOffset: config.xOffset || 0, + zOffset: config.zOffset || 0, + drawingSize: config.drawingSize || 10, + }; +} + +/** + * Convert world coordinates to map pixel coordinates using AC's formula + * Trying with coordinate rotation + */ +/** + * Convert world coordinates to map pixel coordinates + * Based on working formula with potential rotation fix + */ +export function worldToMapCoords( + worldX: number, + worldY: number, + worldZ: number, + config: TrackMapConfig +): { x: number; y: number } { + var aspectRatio = config.width / config.height; + + // Add offsets to player position + + if (aspectRatio < 1) { + worldX = worldX * aspectRatio; + } else { + worldZ = worldZ / aspectRatio; + } + + var x = (worldX) + config.xOffset; + var y = (worldZ) + config.zOffset; + + y /= config.scaleFactor; + x /= config.scaleFactor; + + // Percentages + x = (x * 100) / (config.width); + y = (y * 100) / (config.height); + + return { x, y }; +}