diff --git a/src/app/(home)/log-parser/_components/pvp-blinds.tsx b/src/app/(home)/log-parser/_components/pvp-blinds.tsx new file mode 100644 index 0000000..e68999a --- /dev/null +++ b/src/app/(home)/log-parser/_components/pvp-blinds.tsx @@ -0,0 +1,298 @@ +'use client' + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' + +// Define the structure for hand scores within a PVP blind +export type HandScore = { + timestamp: Date + score: number + handsLeft: number + isLogOwner: boolean +} + +// Define the structure for PVP blind data +export type PvpBlind = { + blindNumber: number + startTimestamp: Date + endTimestamp?: Date + logOwnerScore: number + opponentScore: number + handScores: HandScore[] + winner: 'logOwner' | 'opponent' | null +} + +// Only include the properties needed for this component +type Game = { + pvpBlinds: PvpBlind[] +} + +// Helper to format numbers with scientific notation for large values +function formatNumber(num?: number): string { + if (!num) { + return '-' + } + if (num >= 1e11) { + // Remove the '+' sign from the exponent part + return num.toExponential(2).replace('+', '') + } + return num.toLocaleString() +} + +// Helper function to filter and renumber PVP blinds +function getValidPvpBlinds(pvpBlinds: PvpBlind[]): PvpBlind[] { + // Filter blinds with non-zero scores + const validBlinds = pvpBlinds.filter( + (blind) => blind.logOwnerScore > 0 || blind.opponentScore > 0 + ) + + // Renumber the blinds sequentially + return validBlinds.map((blind, index) => ({ + ...blind, + blindNumber: index + 1, // Start from 1 + })) +} + +function PvpBlindsTable({ + game, + ownerLabel, + opponentLabel, +}: { + game: Game + ownerLabel: string + opponentLabel: string +}) { + const validPvpBlinds = getValidPvpBlinds(game.pvpBlinds) + + if (validPvpBlinds.length === 0) { + return ( +

+ No PVP blind data recorded. +

+ ) + } + + // Calculate totals + const totalLogOwnerScore = validPvpBlinds.reduce( + (sum, blind) => sum + blind.logOwnerScore, + 0 + ) + const totalOpponentScore = validPvpBlinds.reduce( + (sum, blind) => sum + blind.opponentScore, + 0 + ) + + // Determine overall winner + const overallWinner = + totalLogOwnerScore > totalOpponentScore + ? 'logOwner' + : totalOpponentScore > totalLogOwnerScore + ? 'opponent' + : null + + return ( + + + + Blind + {ownerLabel} + + {opponentLabel} + + + + + {validPvpBlinds.map((blind) => ( + + + {blind.blindNumber} + {blind.winner === 'logOwner' ? ' 🏆' : ''} + {blind.winner === 'opponent' ? ' 💀' : ''} + + {/* Log Owner Score */} + + {formatNumber(blind.logOwnerScore)} + + {/* Opponent Score */} + + {formatNumber(blind.opponentScore)} + + + ))} + {/* Totals row */} + + Total + + {formatNumber(totalLogOwnerScore)} + {overallWinner === 'logOwner' ? ' 🏆' : ''} + + + {formatNumber(totalOpponentScore)} + {overallWinner === 'opponent' ? ' 🏆' : ''} + + + +
+ ) +} + +function PvpHandScoresTable({ + blind, + ownerLabel, + opponentLabel, + formatter, +}: { + blind: PvpBlind + ownerLabel: string + opponentLabel: string + formatter: any +}) { + const { handScores } = blind + + if (handScores.length === 0) { + return ( +

+ No hand score data recorded. +

+ ) + } + + // Sort hand scores by timestamp to maintain chronological order + const sortedHandScores = [...handScores].sort( + (a, b) => a.timestamp.getTime() - b.timestamp.getTime() + ) + + // Group hand scores by player and filter out zero scores + const logOwnerScores = sortedHandScores.filter((score) => score.isLogOwner) + const opponentScores = sortedHandScores.filter( + (score) => !score.isLogOwner && score.score > 0 + ) + + // Determine the maximum number of hands + const maxHands = Math.max(logOwnerScores.length, opponentScores.length) + + // Create an array of hand numbers + const handNumbers = Array.from({ length: maxHands }, (_, i) => i + 1) + + // Calculate total scores + const totalLogOwnerScore = logOwnerScores.reduce( + (sum, score) => sum + score.score, + 0 + ) + const totalOpponentScore = opponentScores.reduce( + (sum, score) => sum + score.score, + 0 + ) + + return ( + + + + + Hand # + + {ownerLabel} + + {opponentLabel} + + + + + {handNumbers.map((handNumber, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Simple table rendering + + {handNumber} + + {index < logOwnerScores.length + ? formatNumber(logOwnerScores[index]?.score) + : '-'} + + + {index < opponentScores.length + ? formatNumber(opponentScores[index]?.score) + : '-'} + + + ))} + {/* Total score row */} + + Total + + {formatNumber(totalLogOwnerScore)} + + + {formatNumber(totalOpponentScore)} + + + +
+ ) +} + +export function PvpBlindsCard({ + game, + ownerLabel, + opponentLabel, + formatter, +}: { + game: Game + ownerLabel: string + opponentLabel: string + formatter: any +}) { + const validPvpBlinds = getValidPvpBlinds(game.pvpBlinds) + + if (validPvpBlinds.length === 0) { + return null + } + + return ( + + + PVP Blinds + + + + + {/* Hand Scores for each blind */} + {validPvpBlinds.map((blind) => ( +
+

+ {(() => { + const scoreDiff = Math.abs( + blind.logOwnerScore - blind.opponentScore + ) + let title = `Blind #${blind.blindNumber} Hand Scores` + + if (blind.winner === 'logOwner') { + title += ` (${ownerLabel} won by ${formatNumber(scoreDiff)} chips)` + } else if (blind.winner === 'opponent') { + title += ` (${opponentLabel} won by ${formatNumber(scoreDiff)} chips)` + } + + return title + })()} +

+ +
+ ))} +
+
+ ) +} diff --git a/src/app/(home)/log-parser/page.tsx b/src/app/(home)/log-parser/page.tsx index 71f0f5a..641929e 100644 --- a/src/app/(home)/log-parser/page.tsx +++ b/src/app/(home)/log-parser/page.tsx @@ -36,6 +36,7 @@ import { import { jokers } from '@/shared/jokers' import { useFormatter } from 'next-intl' import { Fragment, useState } from 'react' +import { type PvpBlind, PvpBlindsCard } from './_components/pvp-blinds' // Define the structure for individual log events within a game type LogEvent = { @@ -45,6 +46,8 @@ type LogEvent = { img?: string } +// PVP blind types (PvpBlind and HandScore) are now imported from the PvpBlindsCard component + // Define the structure for game options parsed from lobbyOptions type GameOptions = { back?: string | null // Deck @@ -86,6 +89,8 @@ type Game = { events: LogEvent[] rerolls: number winner: 'logOwner' | 'opponent' | null // Who won the game + pvpBlinds: PvpBlind[] // PVP blind data + currentPvpBlind: number | null // Current PVP blind number } // Helper to initialize a new game object @@ -116,6 +121,8 @@ const initGame = (id: number, startDate: Date): Game => ({ events: [], rerolls: 0, winner: null, + pvpBlinds: [], + currentPvpBlind: null, }) // Helper to format duration @@ -268,27 +275,6 @@ export default function LogParser() { text: `Game ${gameCounter} Started`, type: 'system', }) - currentGame.events.push({ - timestamp, - text: `Host: ${currentGame.host || 'Unknown'}, Guest: ${currentGame.guest || 'Unknown'}`, - type: 'info', - }) - // Add event indicating log owner's role - currentGame.events.push({ - timestamp, - text: `Log Owner Role: ${currentGame.isHost === null ? 'Unknown' : currentGame.isHost ? 'Host' : 'Guest'} (${currentGame.logOwnerName || 'Unknown'})`, - type: 'info', - }) - currentGame.events.push({ - timestamp, - text: `Deck: ${currentGame.deck || 'Unknown'}`, - type: 'info', - }) - currentGame.events.push({ - timestamp, - text: `Seed: ${currentGame.seed || 'Unknown'}`, - type: 'info', - }) continue } @@ -364,6 +350,52 @@ export default function LogParser() { currentGame.opponentLastSkips = newSkips } } + + // Parse opponent score for PVP blind + if (currentGame.currentPvpBlind !== null) { + const scoreMatch = line.match(/score: *(\d+)/) + const handsLeftMatch = line.match(/handsLeft: *(\d+)/) + + if (scoreMatch?.[1]) { + const score = Number.parseInt(scoreMatch[1], 10) + const handsLeft = handsLeftMatch?.[1] + ? Number.parseInt(handsLeftMatch[1], 10) + : 0 + + if (!Number.isNaN(score)) { + const currentBlindIndex = currentGame.currentPvpBlind - 1 + if ( + currentBlindIndex >= 0 && + currentBlindIndex < currentGame.pvpBlinds.length + ) { + const currentBlind = currentGame.pvpBlinds[currentBlindIndex] + if (!currentBlind) { + continue + } + // Update opponent score in current blind + currentBlind.opponentScore += score + + // Add hand score + currentBlind.handScores.push({ + timestamp, + score, + handsLeft, + isLogOwner: false, + }) + + // Add event for opponent score only if score > 0 + if (score > 0) { + currentGame.events.push({ + timestamp, + text: `Opponent score: ${score} (Hands left: ${handsLeft})`, + type: 'event', + }) + } + } + } + } + } + continue } if (line.includes('Client sent message: action:soldCard')) { @@ -454,6 +486,43 @@ export default function LogParser() { continue } + // Parse endPvP messages to determine the winner of each blind + if (line.includes('Client got endPvP message')) { + if (currentGame.currentPvpBlind !== null) { + const lostMatch = line.match(/lost: (true|false)/) + if (lostMatch?.[1]) { + const lost = lostMatch[1].toLowerCase() === 'true' + const currentBlindIndex = currentGame.currentPvpBlind - 1 + + if ( + currentBlindIndex >= 0 && + currentBlindIndex < currentGame.pvpBlinds.length + ) { + const currentBlind = currentGame.pvpBlinds[currentBlindIndex] + if (!currentBlind) { + continue + } + // Set the winner + currentBlind.winner = lost ? 'opponent' : 'logOwner' + + // Set the end timestamp + currentBlind.endTimestamp = timestamp + + // Add event for blind end + currentGame.events.push({ + timestamp, + text: `Ended Blind #${currentBlind.blindNumber} - ${lost ? 'You lost' : 'You won'} (Your score: ${currentBlind.logOwnerScore}, Opponent score: ${currentBlind.opponentScore})`, + type: 'event', + }) + + // Reset current blind + currentGame.currentPvpBlind = null + } + } + } + continue + } + // --- Log Owner Actions/Events (Client sent ...) --- if (lineLower.includes('client sent')) { // Log owner gained/spent money directly @@ -487,7 +556,6 @@ export default function LogParser() { const cardRaw = cardMatch?.[1]?.trim() ?? 'Unknown Card' const cardClean = cardRaw.replace(/^(c_mp_|j_mp_)/, '') const cost = costMatch?.[1] ? Number.parseInt(costMatch[1], 10) : 0 - console.log(cardRaw) currentGame.events.push({ timestamp, img: jokers[cardRaw]?.file, @@ -527,6 +595,52 @@ export default function LogParser() { type: 'action', }) } + } else if (lineLower.includes('playhand')) { + // Log owner played a hand + if (currentGame.currentPvpBlind !== null) { + const scoreMatch = line.match(/score:(\d+)/) + const handsLeftMatch = line.match(/handsLeft:(\d+)/) + + if (scoreMatch?.[1]) { + const score = Number.parseInt(scoreMatch[1], 10) + const handsLeft = handsLeftMatch?.[1] + ? Number.parseInt(handsLeftMatch[1], 10) + : 0 + + if (!Number.isNaN(score)) { + const currentBlindIndex = currentGame.currentPvpBlind - 1 + if ( + currentBlindIndex >= 0 && + currentBlindIndex < currentGame.pvpBlinds.length + ) { + const currentBlind = + currentGame.pvpBlinds[currentBlindIndex] + if (!currentBlind) { + continue + } + // Update log owner score in current blind + currentBlind.logOwnerScore += score + + // Add hand score + currentBlind.handScores.push({ + timestamp, + score, + handsLeft, + isLogOwner: true, + }) + + // Add event for log owner score only if score > 0 + if (score > 0) { + currentGame.events.push({ + timestamp, + text: `Your score: ${score} (Hands left: ${handsLeft})`, + type: 'event', + }) + } + } + } + } + } } else if (lineLower.includes('setlocation')) { // Log owner changed location const locMatch = line.match(/location:([a-zA-Z0-9_-]+)/) @@ -538,6 +652,35 @@ export default function LogParser() { text: `Moved to ${formatLocation(locCode)}`, type: 'status', }) + + // Check if this is a blind location + if (locCode.startsWith('loc_playing-bl_')) { + // Extract blind name + const blindName = locCode.slice('loc_playing-bl_'.length) + + // Increment blind counter + const blindNumber = currentGame.pvpBlinds.length + 1 + + // Create a new PVP blind + currentGame.pvpBlinds.push({ + blindNumber, + startTimestamp: timestamp, + logOwnerScore: 0, + opponentScore: 0, + handScores: [], + winner: null, + }) + + // Set as current blind + currentGame.currentPvpBlind = blindNumber + + // Add event for blind start + currentGame.events.push({ + timestamp, + text: `Started ${formatLocation(locCode)} (Blind #${blindNumber})`, + type: 'event', + }) + } } } } @@ -633,7 +776,11 @@ export default function LogParser() { key={`${game.id}-trigger`} value={`game-${game.id}-${game.logOwnerName || 'LogOwner'}-vs-${game.opponentName || 'Opponent'}`} > - Game {game.id} vs {game.winner === 'opponent' ? `${opponentLabel} 🏆` : opponentLabel}{game.winner === 'logOwner' ? ' 🏆' : ''} + Game {game.id} vs{' '} + {game.winner === 'opponent' + ? `${opponentLabel} 🏆` + : opponentLabel} + {game.winner === 'logOwner' ? ' 🏆' : ''} ) })} @@ -758,7 +905,6 @@ export default function LogParser() {
{game.events.map((event, index) => { - console.log(event.img) return ( // biome-ignore lint/suspicious/noArrayIndexKey: simple list @@ -766,12 +912,14 @@ export default function LogParser() { // biome-ignore lint/suspicious/noArrayIndexKey: Simple list rendering key={index} className={`text-base ${getEventColor(event.type)} ${ - event.text.includes('Opponent') - ? 'flex flex-row-reverse text-right' + event.text.includes('Opponent') + ? 'flex flex-row-reverse text-right' : 'flex' }`} > - + {formatter.dateTime(event.timestamp, { timeStyle: 'medium', })} @@ -779,7 +927,9 @@ export default function LogParser() { {event.text}
{event.img && ( -
+
)} @@ -809,6 +959,14 @@ export default function LogParser() { /> + {/* PVP Blinds Card */} + + @@ -817,7 +975,10 @@ export default function LogParser() {
- {ownerLabel}{game.winner === 'logOwner' ? ' 🏆' : ''}: + + {ownerLabel} + {game.winner === 'logOwner' ? ' 🏆' : ''}: + {game.logOwnerFinalJokers.length > 0 ? (
    {game.logOwnerFinalJokers.map((joker, i) => { @@ -853,7 +1014,10 @@ export default function LogParser() { )}
- {opponentLabel}{game.winner === 'opponent' ? ' 🏆' : ''}: + + {opponentLabel} + {game.winner === 'opponent' ? ' 🏆' : ''}: + {game.opponentFinalJokers.length > 0 ? (
    {game.opponentFinalJokers.map((joker, i) => { @@ -978,10 +1142,12 @@ function ShopSpendingTable({ Shop - {ownerLabel}{game.winner === 'logOwner' ? ' 🏆' : ''} + {ownerLabel} + {game.winner === 'logOwner' ? ' 🏆' : ''} - {opponentLabel}{game.winner === 'opponent' ? ' 🏆' : ''} + {opponentLabel} + {game.winner === 'opponent' ? ' 🏆' : ''} @@ -1053,6 +1219,8 @@ function ShopSpendingTable({ ) } +// PVP blinds components are now imported from the PvpBlindsCard component + // Helper to parse lobby options string (no changes needed) function parseLobbyOptions(optionsStr: string): GameOptions { const options: GameOptions = {} @@ -1092,11 +1260,16 @@ function parseLobbyOptions(optionsStr: string): GameOptions { return options } +// formatNumber function moved to PvpBlindsCard component + // Helper to format location codes (no changes needed) function formatLocation(locCode: string): string { if (locCode === 'loc_shop') { return 'Shop' } + if (locCode === 'loc_playing-bl_mp_nemesis') { + return 'PvP Blind' + } if (locCode.startsWith('loc_playing-')) { const subcode = locCode.slice('loc_playing-'.length) if (subcode.startsWith('bl_')) {