diff --git a/src/app/(home)/log-parser/page.tsx b/src/app/(home)/log-parser/page.tsx index 556481c..bb160cc 100644 --- a/src/app/(home)/log-parser/page.tsx +++ b/src/app/(home)/log-parser/page.tsx @@ -1,5 +1,12 @@ 'use client' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' import { Dropzone, DropzoneDescription, @@ -9,6 +16,7 @@ import { DropzoneUploadIcon, DropzoneZone, } from '@/components/ui/dropzone' +import { ScrollArea } from '@/components/ui/scroll-area' import { Table, TableBody, @@ -17,58 +25,84 @@ import { TableHeader, TableRow, } from '@/components/ui/table' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useFormatter } from 'next-intl' import { useState } from 'react' -type LogLine = { +// Define the structure for individual log events within a game +type LogEvent = { + timestamp: Date text: string - type: 'event' | 'status' | 'system' + type: 'event' | 'status' | 'system' | 'shop' | 'action' | 'error' | 'info' } +// Define the structure for game options parsed from lobbyOptions +type GameOptions = { + back?: string | null // Deck + custom_seed?: string | null + ruleset?: string | null + different_decks?: boolean | null + different_seeds?: boolean | null + death_on_round_loss?: boolean | null + gold_on_life_loss?: boolean | null + no_gold_on_round_loss?: boolean | null + starting_lives?: number | null + stake?: number | null +} + +// Refined Game type to hold structured data type Game = { + id: number // Simple identifier for keys host: string | null guest: string | null - hostMods: string | null - guestMods: string | null + hostMods: string[] + guestMods: string[] + isHost: boolean | null + opponentName: string | null deck: string | null seed: string | null - seedType: string | null - isHost: boolean | null + options: GameOptions | null moneyGained: number moneySpent: number + opponentMoneySpent: number startDate: Date endDate: Date | null + durationSeconds: number | null lastLives: number - opponentMoneySpent: number moneySpentPerShop: (number | null)[] moneySpentPerShopOpponent: (number | null)[] + events: LogEvent[] } -type GameState = { - currentGame: Game | null - games: Game[] -} - -const initGame = (): Game => ({ +// Helper to initialize a new game object +const initGame = (id: number, startDate: Date): Game => ({ + id, host: null, guest: null, - hostMods: null, - guestMods: null, + hostMods: [], + guestMods: [], + isHost: null, + opponentName: null, deck: null, seed: null, - seedType: null, - isHost: null, + options: null, moneyGained: 0, moneySpent: 0, opponentMoneySpent: 0, - startDate: new Date(), + startDate, endDate: null, - lastLives: 4, + durationSeconds: null, + lastLives: 4, // Default starting lives, might be overridden by options moneySpentPerShop: [], moneySpentPerShopOpponent: [], + events: [], }) -const formatDuration = (seconds: number): string => { +// Helper to format duration +const formatDuration = (seconds: number | null): string => { + if (seconds === null || seconds < 0) return 'N/A' + if (seconds === 0) return '0s' + const hours = Math.floor(seconds / 3600) const minutes = Math.floor((seconds % 3600) / 60) const secs = Math.floor(seconds % 60) @@ -81,375 +115,334 @@ const formatDuration = (seconds: number): string => { return parts.join(' ') } +// Helper to convert boolean strings +function boolStrToText(str: string | boolean | undefined | null): string { + if (str === null || str === undefined) return 'Unknown' + if (typeof str === 'boolean') return str ? 'Yes' : 'No' + const lower = str.toLowerCase() + if (lower === 'true') return 'Yes' + if (lower === 'false') return 'No' + return str // Return original if not true/false +} + +// Main component export default function LogParser() { const formatter = useFormatter() - const [logLines, setLogLines] = useState([]) - const [moneyReports, setMoneyReports] = useState< - { - totalSpent: number - totalSpentOpponent: number - spentPerShop: (number | null)[] - spentPerShopOpponent: (number | null)[] - }[] - >([]) - const parseLogFile = async (file: File) => { - const state: GameState = { - currentGame: null, - games: [], - } - let lastSeenDeck = null - let lastSeenLobbyOptions = null + const [parsedGames, setParsedGames] = useState([]) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const parseLogFile = async (file: File) => { + setIsLoading(true) + setError(null) + setParsedGames([]) // Clear previous results - const lines: LogLine[] = [] try { const content = await file.text() const logLines = content.split('\n') - const { seeds, lobbyInfos } = getGamesConfigs(logLines) - console.log(seeds) + + const games: Game[] = [] + let currentGame: Game | null = null + let lastSeenLobbyOptions: GameOptions | null = null + let gameCounter = 0 + + // Pre-process to find lobby info associated with game starts + // This is simplified; a more robust approach might be needed for complex logs + const gameStartInfos = extractGameStartInfo(logLines) + let gameInfoIndex = 0 + for (const line of logLines) { const timeMatch = line.match(/(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/) - const timestamp = timeMatch?.[1] ? new Date(timeMatch[1]) : new Date() + const timestamp = timeMatch?.[1] ? new Date(timeMatch[1]) : new Date() // Use current time as fallback const lineLower = line.toLowerCase() - if (lineLower.includes('enemyinfo')) { - if (!state.currentGame) continue - - const match = line.match(/lives:(\d+)/) - if (match) { - const newLives = match[1] ? Number.parseInt(match[1]) : 0 - if (newLives < state.currentGame.lastLives) { - lines.push({ text: 'Lost a life', type: 'event' }) - } - state.currentGame.lastLives = newLives - } - continue - } - if (line.includes(' Client got spentLastShop message')) { - const match = line.match(/amount: (\d+)/) - if (match) { - if (!state.currentGame) continue - const amount = match[1] ? Number.parseInt(match[1]) : 0 - state.currentGame.opponentMoneySpent += amount - state.currentGame.moneySpentPerShopOpponent.push(amount) - } - } - if (line.includes('Client sent message: action:spentLastShop')) { - const match = line.match(/amount:(\d+)/) - if (match) { - if (!state.currentGame) continue - const amount = match[1] ? Number.parseInt(match[1]) : 0 - state.currentGame.moneySpentPerShop.push(amount) - } - } - if (line.includes('Client sent message: action:skip')) { - if (!state.currentGame) continue - state.currentGame.moneySpentPerShop.push(null) - } - if (lineLower.includes('lobbyinfo message')) { - if (line.includes('host:')) { - const hostMatch = line.match(/host: ([^ )]+)/) - const guestMatch = line.match(/guest: ([^ )]+)/) - const hostModsMatch = line.match(/hostHash: ([^ )]+)/) - const guestModsMatch = line.match(/guestHash: ([^ )]+)/) - - if (state.currentGame) { - state.currentGame.host = hostMatch?.[1] || null - state.currentGame.guest = guestMatch?.[1] || null - state.currentGame.hostMods = hostModsMatch?.[1] || '' - state.currentGame.guestMods = guestModsMatch?.[1] || '' - state.currentGame.isHost = line.includes('isHost: true') - } - } - continue - } - - if (lineLower.includes('lobbyoptions')) { - const parts = line.split(' Client sent message:') - const params = {} as any - const paramsString = parts[1]?.trim()?.split(',') - if (!paramsString) { - continue - } - for (const paramString of paramsString) { - const [key, value] = paramString.split(':') - if (!key || !value) continue - - params[key.trim()] = value.trim() - } - lastSeenDeck = params.back || null - lastSeenLobbyOptions = params - continue - } - + // --- Game Lifecycle --- if (lineLower.includes('startgame message')) { - if (state.currentGame) { - state.currentGame.endDate = new Date() - state.games.push(state.currentGame) + // Finalize previous game if it exists + if (currentGame) { + if (!currentGame.endDate) currentGame.endDate = timestamp // Use current line time if no end signal seen + currentGame.durationSeconds = currentGame.endDate + ? (currentGame.endDate.getTime() - + currentGame.startDate.getTime()) / + 1000 + : null + games.push(currentGame) } - state.currentGame = initGame() - const seedMatch = line.match(/seed:\s*([^) ]+)/) - state.currentGame.startDate = timestamp - state.currentGame.seed = seedMatch?.[1] || null - const lobbyInfo = lobbyInfos.shift() - console.log({ lobbyInfo, lastSeenLobbyOptions }) - lines.push( - { text: '=== New Game Started ===', type: 'system' }, - { - text: `Start Time: ${formatter.dateTime(state.currentGame.startDate, { timeStyle: 'medium', dateStyle: 'medium' })}`, - type: 'system', - }, - { - text: `Deck: ${state.currentGame.deck || lastSeenDeck || 'None'}`, - type: 'system', - }, - { - text: `Seed: ${seeds.shift() || 'Unknown'}`, - type: 'system', - }, - { - text: `Custom Seed: ${lastSeenLobbyOptions?.custom_seed || 'unknown'}`, - type: 'system', - }, - { - text: `Ruleset: ${lastSeenLobbyOptions?.ruleset || 'unknown'}`, - type: 'system', - }, - { - text: `Different decks: ${boolStrToText(lastSeenLobbyOptions?.different_decks)}`, - type: 'system', - }, - { - text: `Different seeds: ${boolStrToText(lastSeenLobbyOptions?.different_seeds)}`, - type: 'system', - }, - { - text: `Death on round loss: ${boolStrToText(lastSeenLobbyOptions?.death_on_round_loss)}`, - type: 'system', - }, - { - text: `Gold on life loss: ${boolStrToText(lastSeenLobbyOptions?.gold_on_life_loss)}`, - type: 'system', - }, - { - text: `No gold on round loss: ${boolStrToText(lastSeenLobbyOptions?.no_gold_on_round_loss)}`, - type: 'system', - }, - { - text: `Starting lives: ${lastSeenLobbyOptions?.starting_lives || 'Unknown'}`, - type: 'system', - }, - { - text: `Stake: ${lastSeenLobbyOptions?.stake || 'Unknown'}`, - type: 'system', - }, - { - text: `Host: ${lobbyInfo?.host || 'Unknown'}`, - type: 'system', - }, - { - text: 'Host mods:', - type: 'system', - }, - ...(lobbyInfo?.hostHash.map((x) => ({ - text: `\t${x}`, - type: 'system' as const, - })) ?? []), - { - text: `Guest: ${lobbyInfo?.guest || 'Unknown'}`, - type: 'system', - }, - { - text: 'Guest mods:', - type: 'system', - }, - ...(lobbyInfo?.guestHash.map((x) => ({ - text: `\t${x}`, - type: 'system' as const, - })) ?? []), - { text: '===================', type: 'system' }, - { text: '', type: 'system' } - ) - continue + // Start new game + gameCounter++ + currentGame = initGame(gameCounter, timestamp) + const currentInfo = gameStartInfos[gameInfoIndex++] ?? {} + + // Apply pre-parsed lobby info and options + currentGame.host = currentInfo.lobbyInfo?.host ?? null + currentGame.guest = currentInfo.lobbyInfo?.guest ?? null + currentGame.hostMods = currentInfo.lobbyInfo?.hostHash ?? [] + currentGame.guestMods = currentInfo.lobbyInfo?.guestHash ?? [] + currentGame.isHost = currentInfo.lobbyInfo?.isHost ?? null + currentGame.opponentName = currentGame.isHost + ? currentGame.guest + : currentGame.host + currentGame.options = lastSeenLobbyOptions // Apply last seen options + currentGame.deck = lastSeenLobbyOptions?.back ?? null + currentGame.seed = currentInfo.seed ?? null // Use seed found near startGame + if (currentGame.options?.starting_lives) { + currentGame.lastLives = currentGame.options.starting_lives + } + + // Add system event for game start + currentGame.events.push({ + timestamp, + text: `Game ${gameCounter} Started`, + type: 'system', + }) + currentGame.events.push({ + timestamp, + text: `Host: ${currentGame.host || 'Unknown'}, Guest: ${currentGame.guest || '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', + }) + // Add more info events for options if needed + continue // Move to next line } if (line.includes('Client got receiveEndGameJokers')) { - if (!state.currentGame) continue - state.currentGame.endDate = timestamp - - const seedMatch = line.match(/seed: ([A-Z0-9]+)/) - state.currentGame.seed = seedMatch?.[1] || null + if (currentGame && !currentGame.endDate) { + currentGame.endDate = timestamp + // Sometimes seed is only available here + const seedMatch = line.match(/seed: ([A-Z0-9]+)/) + if (!currentGame.seed && seedMatch?.[1]) { + currentGame.seed = seedMatch[1] + } + } + continue } - if (!lineLower.includes('client sent')) continue + // --- Lobby and Options Parsing --- + if (lineLower.includes('lobbyoptions')) { + const optionsStr = line.split(' Client sent message:')[1]?.trim() + if (optionsStr) { + lastSeenLobbyOptions = parseLobbyOptions(optionsStr) + // If a game is active, update its options (might happen mid-game?) + if (currentGame && !currentGame.options) { + currentGame.options = lastSeenLobbyOptions + currentGame.deck = lastSeenLobbyOptions.back ?? currentGame.deck + if (lastSeenLobbyOptions.starting_lives) { + currentGame.lastLives = lastSeenLobbyOptions.starting_lives + } + } + } + continue + } - if (lineLower.includes('moneymoved')) { - if (!state.currentGame) continue + // --- In-Game Event Parsing (requires currentGame) --- + if (!currentGame) continue // Skip lines if no game is active - const match = line.match(/amount: *(-?\d+)/) - if (match) { - const amount = match[1] ? Number.parseInt(match[1]) : -1 - if (amount >= 0) { - state.currentGame.moneyGained += amount - lines.push({ - text: `Gained $${amount} (Total gained: $${state.currentGame.moneyGained})`, - type: 'event', - }) - } else { - const spent = Math.abs(amount) - lines.push({ - text: `Spent $${spent}`, + if (lineLower.includes('enemyinfo')) { + const match = line.match(/lives:(\d+)/) + if (match?.[1]) { + const newLives = Number.parseInt(match[1], 10) + if (!isNaN(newLives) && newLives < currentGame.lastLives) { + currentGame.events.push({ + timestamp, + text: `Opponent lost a life (${currentGame.lastLives} -> ${newLives})`, type: 'event', }) } + currentGame.lastLives = newLives } - } else if (lineLower.includes('spentlastshop')) { - if (!state.currentGame) continue - - const match = line.match(/amount: *(\d+)/) - if (match) { - const amount = match[1] ? Number.parseInt(match[1]) : -1 - state.currentGame.moneySpent += amount - lines.push({ - text: `Spent $${amount} last shop (Total spent: $${state.currentGame.moneySpent})`, - type: 'event', + continue + } + if (line.includes('Client sent message: action:soldCard')) { + const match = line.match(/card:(.+)$/) + if (match?.[1]) { + const card = match[1].trim() + currentGame.events.push({ + timestamp, + text: `Sold ${card}`, + type: 'shop', }) } - } else if (line.includes('boughtCardFromShop')) { - const match = line.match(/card:([^,\n]+)/i) - if (match) { - const raw = match[1] ? match[1].trim() : '' - const clean = raw.replace(/^(c_mp_|j_mp_)/, '') - lines.push({ text: `Bought ${clean}`, type: 'event' }) - } - } else if (line.includes('rerollShop')) { - const match = line.match(/cost:([^,\n]+)/i) - if (match) { - const cost = match[1] ? match[1].trim() : '' + continue + } - lines.push({ text: `Reroll for $${cost}`, type: 'event' }) + if (line.includes(' Client got spentLastShop message')) { + const match = line.match(/amount: (\d+)/) + if (match?.[1]) { + const amount = Number.parseInt(match[1], 10) + if (!isNaN(amount)) { + currentGame.opponentMoneySpent += amount + currentGame.moneySpentPerShopOpponent.push(amount) + currentGame.events.push({ + timestamp, + text: `Opponent spent $${amount} in shop`, + type: 'shop', + }) + } } - } else if (lineLower.includes('usedcard')) { - const match = line.match(/card:([^,\n]+)/i) - if (match) { - const raw = match[1] ? match[1].trim() : '' - const clean = raw.replace(/^(c_mp_|j_mp_)/, '') - const pretty = clean - .replace(/_/g, ' ') - .replace( - /\w\S*/g, - (txt) => - txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() - ) - lines.push({ text: `Used ${pretty}`, type: 'event' }) - } - } else if (lineLower.includes('setlocation')) { - const locMatch = line.match(/location:([a-zA-Z0-9_-]+)/) - if (locMatch) { - const locCode = locMatch[1] - if (locCode === 'loc_selecting' || !locCode) continue + continue + } - let locationText: string - if (locCode === 'loc_shop') { - locationText = 'Shop' - } else if (locCode.startsWith('loc_playing-')) { - const subcode = locCode.slice('loc_playing-'.length) - if (subcode.startsWith('bl_')) { - const blindName = subcode - .slice(3) - .replace(/_/g, ' ') - .replace( - /\w\S*/g, - (txt) => - txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() - ) - locationText = `${blindName} Blind` - } else { - const readable = subcode - .replace(/_/g, ' ') - .replace( - /\w\S*/g, - (txt) => - txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() - ) - locationText = `Playing ${readable}` + if (line.includes('Client sent message: action:spentLastShop')) { + const match = line.match(/amount:(\d+)/) + if (match?.[1]) { + const amount = Number.parseInt(match[1], 10) + if (!isNaN(amount)) { + currentGame.moneySpentPerShop.push(amount) + // Note: Total money spent is tracked via moneymoved/reroll/buy + currentGame.events.push({ + timestamp, + text: `Reported spending $${amount} last shop`, + type: 'shop', + }) + } + } + continue + } + + if (line.includes('Client sent message: action:skip')) { + currentGame.moneySpentPerShop.push(null) // Mark shop as skipped + currentGame.events.push({ + timestamp, + text: 'Skipped shop', + type: 'shop', + }) + continue + } + + // --- Player Actions/Events --- + if (lineLower.includes('client sent')) { + if (lineLower.includes('moneymoved')) { + const match = line.match(/amount: *(-?\d+)/) + if (match?.[1]) { + const amount = Number.parseInt(match[1], 10) + if (!isNaN(amount)) { + if (amount >= 0) { + currentGame.moneyGained += amount + currentGame.events.push({ + timestamp, + text: `Gained $${amount}`, + type: 'event', + }) + } else { + const spent = Math.abs(amount) + currentGame.moneySpent += spent // Track spending here + currentGame.events.push({ + timestamp, + text: `Spent $${spent}`, + type: 'event', + }) + } } - } else { - locationText = locCode + } + } else if (line.includes('boughtCardFromShop')) { + const cardMatch = line.match(/card:([^,\n]+)/i) + const costMatch = line.match(/cost: *(\d+)/i) // Assuming cost is logged + 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 + if (cost > 0) currentGame.moneySpent += cost // Add purchase cost + currentGame.events.push({ + timestamp, + text: `Bought ${cardClean}${cost > 0 ? ` for $${cost}` : ''}`, + type: 'shop', + }) + } else if (line.includes('rerollShop')) { + const costMatch = line.match(/cost: *(\d+)/i) + if (costMatch?.[1]) { + const cost = Number.parseInt(costMatch[1], 10) + if (!isNaN(cost)) { + currentGame.moneySpent += cost // Add reroll cost + currentGame.events.push({ + timestamp, + text: `Rerolled shop for $${cost}`, + type: 'shop', + }) + } + } + } else if (lineLower.includes('usedcard')) { + const match = line.match(/card:([^,\n]+)/i) + if (match?.[1]) { + const raw = match[1].trim() + const clean = raw + .replace(/^(c_mp_|j_mp_)/, '') .replace(/_/g, ' ') .replace( /\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() ) + currentGame.events.push({ + timestamp, + text: `Used ${clean}`, + type: 'action', + }) + } + } else if (lineLower.includes('setlocation')) { + const locMatch = line.match(/location:([a-zA-Z0-9_-]+)/) + if (locMatch?.[1]) { + const locCode = locMatch[1] + if (locCode !== 'loc_selecting' && locCode) { + currentGame.events.push({ + timestamp, + text: `Moved to ${formatLocation(locCode)}`, + type: 'status', + }) + } } - lines.push({ text: locationText, type: 'status' }) } } + } // End of line processing loop + + // Add the last game if it exists + if (currentGame) { + if (!currentGame.endDate) { + // Find the timestamp of the last event or the last line processed + const lastEventTime = + currentGame.events.length > 0 + ? currentGame.events[currentGame.events.length - 1].timestamp + : null + const lastLineTime = timeMatch?.[1] ? new Date(timeMatch[1]) : null + currentGame.endDate = + lastEventTime ?? lastLineTime ?? currentGame.startDate // Fallback chain + } + currentGame.durationSeconds = currentGame.endDate + ? (currentGame.endDate.getTime() - currentGame.startDate.getTime()) / + 1000 + : null + games.push(currentGame) } - if (state.currentGame) { - // state.currentGame.endDate = new Date() - state.games.push(state.currentGame) + if (games.length === 0) { + setError('No games found in the log file.') } - state.games.forEach((game, i) => { - const duration = game.endDate - ? (game.endDate.getTime() - game.startDate.getTime()) / 1000 - : 0 - - const startTimeStr = game.startDate.toLocaleTimeString() - const endTimeStr = game.endDate?.toLocaleTimeString() || 'Unknown' - - const summaryLines = [ - { text: `=== Game ${i + 1} Summary ===`, type: 'system' }, - { text: `Start Time: ${startTimeStr}`, type: 'system' }, - { text: `End Time: ${endTimeStr}`, type: 'system' }, - { text: `Duration: ${formatDuration(duration)}`, type: 'system' }, - { text: `Total Money Gained: $${game.moneyGained}`, type: 'system' }, - { text: `Total Money Spent: $${game.moneySpent}`, type: 'system' }, - { - text: `Net Money: $${game.moneyGained - game.moneySpent}`, - type: 'system', - }, - { text: '==================', type: 'system' }, - { text: '', type: 'system' }, - ] as LogLine[] - - lines.unshift(...summaryLines) - }) - - const totalGained = state.games.reduce((sum, g) => sum + g.moneyGained, 0) - const totalSpent = state.games.reduce((sum, g) => sum + g.moneySpent, 0) - - lines.unshift( - { text: '=== Overall Summary ===', type: 'system' }, - { text: `Total Games: ${state.games.length}`, type: 'system' }, - { text: `Total Money Gained: $${totalGained}`, type: 'system' }, - { text: `Total Money Spent: $${totalSpent}`, type: 'system' }, - { - text: `Overall Net Money: $${totalGained - totalSpent}`, - type: 'system', - }, - { text: '==================', type: 'system' }, - { text: '', type: 'system' } - ) - - setMoneyReports( - state.games.map((game) => ({ - totalSpent: game.moneySpent, - totalSpentOpponent: game.opponentMoneySpent, - spentPerShop: game.moneySpentPerShop, - spentPerShopOpponent: game.moneySpentPerShopOpponent, - })) - ) - setLogLines(lines) + setParsedGames(games) } catch (err) { console.error('Error parsing log:', err) - throw err + setError( + `Failed to parse log file. ${err instanceof Error ? err.message : 'Unknown error'}` + ) + setParsedGames([]) + } finally { + setIsLoading(false) } } + const defaultTabValue = + parsedGames.length > 0 + ? `game-${parsedGames[0].id}-${parsedGames[0].opponentName || 'Unknown'}` + : '' + return (
{ const file = files[0] - if (!(file instanceof File)) { - return + if (file instanceof File) { + parseLogFile(file) } - return parseLogFile(file) }} + disabled={isLoading} > - Drop files here or click to upload + Drop log file here or click - Upload your corrupted profile.jkr and get a - fixed version. + Upload your Balatro log.txt file. -
-
- {logLines.map((line, i) => ( -
- key={i} - className={`whitespace-pre py-2 ${ - line.type === 'event' - ? 'text-blue-400' - : line.type === 'status' - ? 'text-green-400' - : 'font-mono text-gray-400' - }`} + {isLoading &&

Loading and parsing log...

} + {error &&

{error}

} + + {parsedGames.length > 0 && ( + + + {parsedGames.map((game) => ( + + Game {game.id}: {game.opponentName || 'Unknown'} + + ))} + + + {parsedGames.map((game) => ( + - {line.text} -
+ + + + Game {game.id} vs {game.opponentName || 'Unknown'} + + + Started:{' '} + {formatter.dateTime(game.startDate, { + dateStyle: 'short', + timeStyle: 'short', + })}{' '} + | Ended:{' '} + {game.endDate + ? formatter.dateTime(game.endDate, { + dateStyle: 'short', + timeStyle: 'short', + }) + : 'N/A'}{' '} + | Duration: {formatDuration(game.durationSeconds)} + + + + {/* Column 1: Game Info & Events */} +
+ + + Game Details + + +

+ You Were:{' '} + {game.isHost ? 'Host' : 'Guest'} ( + {game.isHost ? game.host : game.guest}) +

+

+ Deck: {game.deck || 'Unknown'} +

+

+ Seed: {game.seed || 'Unknown'} +

+

+ Ruleset:{' '} + {game.options?.ruleset || 'Default'} +

+

+ Stake:{' '} + {game.options?.stake ?? 'Unknown'} +

+ {/* Add more options as needed */} +

+ Different Decks:{' '} + {boolStrToText(game.options?.different_decks)} +

+

+ Different Seeds:{' '} + {boolStrToText(game.options?.different_seeds)} +

+

+ Death on Round Loss:{' '} + {boolStrToText(game.options?.death_on_round_loss)} +

+

+ Gold on Life Loss:{' '} + {boolStrToText(game.options?.gold_on_life_loss)} +

+

+ No Gold on Round Loss:{' '} + {boolStrToText(game.options?.no_gold_on_round_loss)} +

+
+
+ + + Events + + + +
+ {game.events.map((event, index) => ( +
+ + {formatter.dateTime(event.timestamp, { + timeStyle: 'medium', + })} + + {event.text} +
+ ))} +
+
+
+
+
+ + {/* Column 2: Money & Mods */} +
+ + + Shop Spending + + + + + + + + Mods + + +
+ Host ({game.host || 'Unknown'}) Mods: + {game.hostMods.length > 0 ? ( +
    + {game.hostMods.map((mod, i) => ( +
  • {mod}
  • + ))} +
+ ) : ( +

+ None detected +

+ )} +
+
+ + Guest ({game.guest || 'Unknown'}) Mods: + + {game.guestMods.length > 0 ? ( +
    + {game.guestMods.map((mod, i) => ( +
  • {mod}
  • + ))} +
+ ) : ( +

+ None detected +

+ )} +
+
+
+
+
+
+ ))} -
-
- {moneyReports.map((report, i) => { - const mostShops = - report.spentPerShop.length > report.spentPerShopOpponent.length - ? report.spentPerShop.length - : report.spentPerShopOpponent.length - return ( - // biome-ignore lint/suspicious/noArrayIndexKey: -
-
Game {i + 1}
- - - - - Shop # - - - Logs owner - - - Opponent - - - - - {Array.from({ length: mostShops }).map((_, j) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: - - - {j + 1} - - - {report.spentPerShop[j] ?? 'Skipped'} - - - {report.spentPerShopOpponent[j] ?? 'Skipped'} - - - ))} - - Total - - {report.totalSpent} - - - {report.totalSpentOpponent} - - - -
-
- ) - })} -
-
+ + )}
) } -function getGamesConfigs(lines: string[]) { - const seeds = [] - const lobbyInfos = [] - let latestLobbyInfo: string | null = null +// --- Helper Functions --- - for (const line of lines) { - if (line.includes('Client got lobbyInfo message')) { - latestLobbyInfo = line - continue - } - if (line.includes('Client got startGame message')) { - if (!latestLobbyInfo) continue - const lobbyInfo = parseLobbyInfo(latestLobbyInfo) - lobbyInfos.push(lobbyInfo) - } - if (line.includes('Client got receiveEndGameJokers message')) { - const match = line.match(/seed: ([A-Z0-9]+)/) - if (match) { - const seed = match[1] - seeds.push(seed) - } - } +// Simple component for the shop spending table +function ShopSpendingTable({ game }: { game: Game }) { + const maxShops = Math.max( + game.moneySpentPerShop.length, + game.moneySpentPerShopOpponent.length + ) + + if ( + maxShops === 0 && + game.moneySpent === 0 && + game.opponentMoneySpent === 0 + ) { + return ( +

+ No shop spending data recorded. +

+ ) } - return { seeds, lobbyInfos } + + return ( + + + + Shop + You + Opponent + + + + {Array.from({ length: maxShops }).map((_, j) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Simple table rendering + + {j + 1} + + {game.moneySpentPerShop[j] === null + ? 'Skipped' + : game.moneySpentPerShop[j] !== undefined + ? `$${game.moneySpentPerShop[j]}` + : '-'} + + + {game.moneySpentPerShopOpponent[j] === null + ? 'Skipped' + : game.moneySpentPerShopOpponent[j] !== undefined + ? `$${game.moneySpentPerShopOpponent[j]}` + : '-'} + + + ))} + + Total Reported + + $ + {game.moneySpentPerShop + .filter((v): v is number => v !== null) + .reduce((a, b) => a + b, 0)} + + + $ + {game.moneySpentPerShopOpponent + .filter((v): v is number => v !== null) + .reduce((a, b) => a + b, 0)} + + + + Total Actual + + ${game.moneySpent} + + + ${game.opponentMoneySpent} + + + +
+ ) } -type LogEntry = { +// Helper to parse lobby options string +function parseLobbyOptions(optionsStr: string): GameOptions { + const options: GameOptions = {} + const params = optionsStr.split(',') + for (const param of params) { + const [key, value] = param.split(':') + const trimmedKey = key?.trim() + const trimmedValue = value?.trim() + if (!trimmedKey || !trimmedValue) continue + + switch (trimmedKey) { + case 'back': + options.back = trimmedValue + break + case 'custom_seed': + options.custom_seed = trimmedValue + break + case 'ruleset': + options.ruleset = trimmedValue + break + case 'different_decks': + case 'different_seeds': + case 'death_on_round_loss': + case 'gold_on_life_loss': + case 'no_gold_on_round_loss': + options[trimmedKey] = trimmedValue.toLowerCase() === 'true' + break + case 'starting_lives': + case 'stake': + const numValue = Number.parseInt(trimmedValue, 10) + if (!isNaN(numValue)) { + options[trimmedKey] = numValue + } + break + } + } + return options +} + +// Helper to format location codes +function formatLocation(locCode: string): string { + if (locCode === 'loc_shop') { + return 'Shop' + } + if (locCode.startsWith('loc_playing-')) { + const subcode = locCode.slice('loc_playing-'.length) + if (subcode.startsWith('bl_')) { + const blindName = subcode + .slice(3) + .replace(/_/g, ' ') + .replace( + /\w\S*/g, + (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() + ) + return `${blindName} Blind` + } + { + const readable = subcode + .replace(/_/g, ' ') + .replace( + /\w\S*/g, + (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() + ) + return `Playing ${readable}` + } + } + return locCode + .replace(/_/g, ' ') + .replace( + /\w\S*/g, + (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() + ) +} + +// Helper to get color class based on event type +function getEventColor(type: LogEvent['type']): string { + switch (type) { + case 'event': + return 'text-blue-400' + case 'status': + return 'text-green-400' + case 'system': + return 'text-purple-400' + case 'shop': + return 'text-yellow-400' + case 'action': + return 'text-cyan-400' + case 'info': + return 'text-gray-400' + case 'error': + return 'text-red-500' + default: + return 'text-gray-500' + } +} + +// --- Log Pre-processing Helpers --- + +// Simplified structure for lobby info parsing result +type ParsedLobbyInfo = { timestamp: Date - level: string - context: string - message: string + host: string | null + guest: string | null hostHash: string[] guestHash: string[] - action: string - guestCached: boolean - hostCached: boolean - guest: string - host: string - isHost: boolean + isHost: boolean | null + // Add other fields if needed from parseLobbyInfo } -function parseLobbyInfo(line: string) { - const regex = - /^(INFO|ERROR|WARN|DEBUG) - \[(\w+)\] (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) :: (\w+) :: (\w+) :: (.+)$/ - const matches = line.match(regex) +// Structure to hold info related to a game start +type GameStartInfo = { + lobbyInfo: ParsedLobbyInfo | null + seed: string | null +} - if (!matches || matches.length < 7) { - throw new Error('Invalid log line format') - } +// Function to extract lobby info and seeds associated with game starts +function extractGameStartInfo(lines: string[]): GameStartInfo[] { + const gameInfos: GameStartInfo[] = [] + let latestLobbyInfo: ParsedLobbyInfo | null = null + let nextGameSeed: string | null = null // Seed often appears *after* start - const [, level, context, timestampStr, type, category, message] = matches + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const lineLower = line.toLowerCase() - if (!timestampStr || !level || !context || !message) { - throw new Error('Missing required log components') - } - - const entry: LogEntry = { - timestamp: new Date(timestampStr), - level, - context, - message, - hostHash: [], - guestHash: [], - action: '', - guestCached: false, - hostCached: false, - guest: '', - host: '', - isHost: false, - } - - const pairs = message.match(/\(([^)]+)\)/g) || [] - for (const pair of pairs) { - const keyValue = pair.slice(1, -1).split(': ') - if (keyValue.length !== 2) continue - - const [key, value] = keyValue.map((s) => s.trim()) - if (!value || !key) { - continue + // Capture the latest lobby info seen + if (line.includes('Client got lobbyInfo message')) { + try { + latestLobbyInfo = parseLobbyInfoLine(line) // Use a dedicated parser + } catch (e) { + console.warn('Could not parse lobbyInfo line:', line, e) + latestLobbyInfo = null // Reset if parsing fails + } } - switch (key) { - case 'hostHash': - case 'guestHash': - ;(entry[key] as string[]) = value.split(';') - break - case 'guestCached': - case 'hostCached': - case 'isHost': - ;(entry[key] as boolean) = value.toLowerCase() === 'true' - break - default: - if (key in entry) { - ;(entry[key as keyof LogEntry] as string) = value - } + + // Capture seed from endgame message (often relates to the *next* game's seed if custom) + // Or seed from the startGame message itself + if (line.includes('Client got receiveEndGameJokers message')) { + const seedMatch = line.match(/seed: ([A-Z0-9]+)/) + if (seedMatch?.[1]) { + // This seed might belong to the game that just ended, + // or potentially the *next* one if using custom seeds? + // Let's tentatively store it for the *next* game start. + // A more robust logic might be needed depending on exact log behavior. + // nextGameSeed = seedMatch[1]; // Let's disable this for now, seed on start is more reliable + } + } + + // When a game starts, associate the latest lobby info and potentially the seed + if (lineLower.includes('startgame message')) { + const seedMatch = line.match(/seed:\s*([^) ]+)/) + const startGameSeed = seedMatch?.[1] || null + + gameInfos.push({ + lobbyInfo: latestLobbyInfo, + seed: startGameSeed ?? nextGameSeed, // Prefer seed from start message + }) + // Reset for the next game + latestLobbyInfo = null + nextGameSeed = null } } - - return entry + return gameInfos } -function boolStrToText(str: string | undefined | null) { - if (!str) { - return 'Unknown' +// Parses a single lobbyInfo log line (adapt your original parseLobbyInfo) +function parseLobbyInfoLine(line: string): ParsedLobbyInfo | null { + // Basic parsing, adjust regex/logic based on your exact log format + const timeMatch = line.match(/(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/) + const timestamp = timeMatch?.[1] ? new Date(timeMatch[1]) : new Date() + + const hostMatch = line.match(/host: ([^ )]+)/) + const guestMatch = line.match(/guest: ([^ )]+)/) + const hostHashMatch = line.match(/hostHash: ([^)]+)/) // Capture content within parenthesis potentially + const guestHashMatch = line.match(/guestHash: ([^)]+)/) + const isHostMatch = line.includes('isHost: true') + + // Clean up hash strings (remove parenthesis if captured, split by ';') + const cleanHash = (hashStr: string | null | undefined) => { + if (!hashStr) return [] + return hashStr + .replace(/[()]/g, '') + .split(';') + .map((s) => s.trim()) + .filter(Boolean) } - if (str === 'true') { - return 'Yes' + + return { + timestamp, + host: hostMatch?.[1] || null, + guest: guestMatch?.[1] || null, + hostHash: cleanHash(hostHashMatch?.[1]), + guestHash: cleanHash(guestHashMatch?.[1]), + isHost: isHostMatch, } - if (str === 'false') { - return 'No' - } - return str } + +// Original boolStrToText - kept for reference if needed elsewhere +// function boolStrToText(str: string | undefined | null) { +// if (!str) { +// return 'Unknown' +// } +// if (str === 'true') { +// return 'Yes' +// } +// if (str === 'false') { +// return 'No' +// } +// return str +// } + +// Original getGamesConfigs - replaced by extractGameStartInfo +// function getGamesConfigs(lines: string[]) { ... } + +// Original parseLobbyInfo - replaced by parseLobbyInfoLine +// function parseLobbyInfo(line: string) { ... }