add vouchers display
BIN
public/cards/v_antimatter.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/cards/v_blank.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/cards/v_clearance_sale.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/cards/v_crystal_ball.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/cards/v_directors_cut.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/cards/v_glow_up.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/cards/v_grabber.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/cards/v_hieroglyph.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/cards/v_hone.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/cards/v_illusion.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/cards/v_liquidation.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/cards/v_locked.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/cards/v_magic_trick.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/cards/v_money_tree.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/cards/v_nacho_tong.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/cards/v_observatory.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/cards/v_omen_globe.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/cards/v_overstock_norm.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/cards/v_overstock_plus.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/cards/v_paint_brush.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/cards/v_palette.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/cards/v_petroglyph.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
public/cards/v_planet_merchant.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/cards/v_planet_tycoon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/cards/v_recyclomancy.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/cards/v_reroll_glut.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/cards/v_reroll_surplus.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/cards/v_retcon.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/cards/v_seed_money.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/cards/v_tarot_merchant.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/cards/v_tarot_tycoon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/cards/v_telescope.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/cards/v_undiscovered.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/cards/v_wasteful.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
@@ -34,6 +34,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { jokers } from '@/shared/jokers'
|
||||
import { vouchers } from '@/shared/vouchers'
|
||||
import { useFormatter } from 'next-intl'
|
||||
import Image from 'next/image'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
@@ -107,7 +108,12 @@ type Game = {
|
||||
logOwnerFinalJokers: string[] // Log owner's final jokers
|
||||
opponentFinalJokers: string[] // Opponent's final jokers
|
||||
events: LogEvent[]
|
||||
rerolls: number
|
||||
rerolls: number // Log owner's reroll count
|
||||
rerollCostTotal: number // Log owner's total reroll cost
|
||||
logOwnerVouchers: string[] // Log owner's vouchers
|
||||
opponentRerolls: number // Opponent's reroll count
|
||||
opponentRerollCostTotal: number // Opponent's total reroll cost
|
||||
opponentVouchers: string[] // Opponent's vouchers
|
||||
winner: 'logOwner' | 'opponent' | null // Who won the game
|
||||
pvpBlinds: PvpBlind[] // PVP blind data
|
||||
currentPvpBlind: number | null // Current PVP blind number
|
||||
@@ -140,6 +146,11 @@ const initGame = (id: number, startDate: Date): Game => ({
|
||||
opponentFinalJokers: [],
|
||||
events: [],
|
||||
rerolls: 0,
|
||||
rerollCostTotal: 0,
|
||||
logOwnerVouchers: [],
|
||||
opponentRerolls: 0,
|
||||
opponentRerollCostTotal: 0,
|
||||
opponentVouchers: [],
|
||||
winner: null,
|
||||
pvpBlinds: [],
|
||||
currentPvpBlind: null,
|
||||
@@ -347,6 +358,34 @@ export default function LogParser() {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (line.includes('Client got nemesisEndGameStats message')) {
|
||||
if (currentGame) {
|
||||
// Extract Opponent Reroll Count
|
||||
const rerollCountMatch = line.match(/\(reroll_count: (\d+)\)/)
|
||||
if (rerollCountMatch?.[1]) {
|
||||
currentGame.opponentRerolls = Number.parseInt(
|
||||
rerollCountMatch[1],
|
||||
10
|
||||
)
|
||||
}
|
||||
|
||||
// Extract Opponent Reroll Cost Total
|
||||
const rerollCostMatch = line.match(/\(reroll_cost_total: (\d+)\)/)
|
||||
if (rerollCostMatch?.[1]) {
|
||||
currentGame.opponentRerollCostTotal = Number.parseInt(
|
||||
rerollCostMatch[1],
|
||||
10
|
||||
)
|
||||
}
|
||||
|
||||
// Extract Opponent Vouchers
|
||||
const vouchersMatch = line.match(/\(vouchers: ([^)]+)\)/)
|
||||
if (vouchersMatch?.[1]) {
|
||||
currentGame.opponentVouchers = vouchersMatch[1].split('-')
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (line.includes('Client sent message: action:receiveEndGameJokers')) {
|
||||
if (currentGame) {
|
||||
// Mark end date if not already set (might happen slightly before 'got')
|
||||
@@ -362,6 +401,31 @@ export default function LogParser() {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (line.includes('Client sent message: action:nemesisEndGameStats')) {
|
||||
if (currentGame) {
|
||||
// Extract Log Owner Reroll Count
|
||||
const rerollCountMatch = line.match(/reroll_count:(\d+)/)
|
||||
if (rerollCountMatch?.[1]) {
|
||||
currentGame.rerolls = Number.parseInt(rerollCountMatch[1], 10)
|
||||
}
|
||||
|
||||
// Extract Log Owner Reroll Cost Total
|
||||
const rerollCostMatch = line.match(/reroll_cost_total:(\d+)/)
|
||||
if (rerollCostMatch?.[1]) {
|
||||
currentGame.rerollCostTotal = Number.parseInt(
|
||||
rerollCostMatch[1],
|
||||
10
|
||||
)
|
||||
}
|
||||
|
||||
// Extract Log Owner Vouchers
|
||||
const vouchersMatch = line.match(/vouchers:(.+)$/)
|
||||
if (vouchersMatch?.[1]) {
|
||||
currentGame.logOwnerVouchers = vouchersMatch[1].split('-')
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (lineLower.includes('startgame message')) {
|
||||
if (currentGame) {
|
||||
if (!currentGame.endDate) currentGame.endDate = timestamp
|
||||
@@ -855,7 +919,8 @@ export default function LogParser() {
|
||||
? ((currentGame.endDate instanceof Date
|
||||
? currentGame.endDate.getTime()
|
||||
: new Date(currentGame.endDate).getTime()) -
|
||||
currentGame.startDate.getTime()) / 1000
|
||||
currentGame.startDate.getTime()) /
|
||||
1000
|
||||
: null
|
||||
games.push(currentGame)
|
||||
}
|
||||
@@ -1049,8 +1114,18 @@ export default function LogParser() {
|
||||
: opponentLabel}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Rerolls:</strong>{' '}
|
||||
<strong>{ownerLabel} Rerolls:</strong>{' '}
|
||||
{game.rerolls || 'Unknown'}
|
||||
{game.rerollCostTotal
|
||||
? ` (Cost: ${game.rerollCostTotal})`
|
||||
: ''}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{opponentLabel} Rerolls:</strong>{' '}
|
||||
{game.opponentRerolls || 'Unknown'}
|
||||
{game.opponentRerollCostTotal
|
||||
? ` (Cost: ${game.opponentRerollCostTotal})`
|
||||
: ''}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Deck:</strong> {game.deck || 'Unknown'}
|
||||
@@ -1277,6 +1352,93 @@ export default function LogParser() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-lg'>Vouchers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3 text-sm'>
|
||||
<div>
|
||||
<strong>
|
||||
{ownerLabel}
|
||||
{game.winner === 'logOwner' ? ' 🏆' : ''}:
|
||||
</strong>
|
||||
{game.logOwnerVouchers.length > 0 ? (
|
||||
<ul className='mt-3 ml-4 flex list-inside gap-3'>
|
||||
{game.logOwnerVouchers.map((voucher, i) => {
|
||||
if (!voucher) {
|
||||
return null
|
||||
}
|
||||
const cleanName =
|
||||
vouchers[voucher]?.name ??
|
||||
cleanVoucherKey(voucher)
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Simple list
|
||||
<li key={i} className={'list-none'}>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center justify-center gap-2'
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={`/cards/${voucher}.png`}
|
||||
alt={cleanName}
|
||||
width={142}
|
||||
height={190}
|
||||
/>
|
||||
<span>{cleanName}</span>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className='text-gray-500 italic'>
|
||||
No data found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>
|
||||
{opponentLabel}
|
||||
{game.winner === 'opponent' ? ' 🏆' : ''}:
|
||||
</strong>
|
||||
{game.opponentVouchers.length > 0 ? (
|
||||
<ul className='mt-3 ml-4 flex list-inside gap-3'>
|
||||
{game.opponentVouchers.map((voucher, i) => {
|
||||
if (!voucher) {
|
||||
return null
|
||||
}
|
||||
const cleanName =
|
||||
vouchers[voucher]?.name ??
|
||||
cleanVoucherKey(voucher)
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Simple list
|
||||
<li key={i} className={'list-none'}>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center justify-center gap-2'
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={`/cards/${voucher}.png`}
|
||||
alt={cleanName}
|
||||
width={142}
|
||||
height={190}
|
||||
/>
|
||||
<span>{cleanName}</span>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className='text-gray-500 italic'>
|
||||
No data found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-lg'>Mods</CardTitle>
|
||||
@@ -1637,6 +1799,18 @@ function cleanJokerKey(key: string): string {
|
||||
)
|
||||
}
|
||||
|
||||
function cleanVoucherKey(key: string): string {
|
||||
if (!key) return ''
|
||||
return key
|
||||
.trim()
|
||||
.replace(/^v_/, '') // Remove prefix v_
|
||||
.replace(/_/g, ' ') // Replace underscores with spaces
|
||||
.replace(
|
||||
/\w\S*/g, // Capitalize each word (Title Case)
|
||||
(txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
type JsonValue =
|
||||
| string
|
||||
| number
|
||||
|
||||
41
src/shared/vouchers.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export interface VoucherInfo {
|
||||
name: string
|
||||
file: string
|
||||
}
|
||||
|
||||
export const vouchers: { [key: string]: VoucherInfo } = {
|
||||
v_antimatter: { name: 'Antimatter', file: 'v_antimatter.png' },
|
||||
v_blank: { name: 'Blank', file: 'v_blank.png' },
|
||||
v_clearance_sale: { name: 'Clearance Sale', file: 'v_clearance_sale.png' },
|
||||
v_crystal_ball: { name: 'Crystal Ball', file: 'v_crystal_ball.png' },
|
||||
v_directors_cut: { name: "Director's Cut", file: 'v_directors_cut.png' },
|
||||
v_glow_up: { name: 'Glow Up', file: 'v_glow_up.png' },
|
||||
v_grabber: { name: 'Grabber', file: 'v_grabber.png' },
|
||||
v_hieroglyph: { name: 'Hieroglyph', file: 'v_hieroglyph.png' },
|
||||
v_hone: { name: 'Hone', file: 'v_hone.png' },
|
||||
v_illusion: { name: 'Illusion', file: 'v_illusion.png' },
|
||||
v_liquidation: { name: 'Liquidation', file: 'v_liquidation.png' },
|
||||
v_locked: { name: 'Locked', file: 'v_locked.png' },
|
||||
v_magic_trick: { name: 'Magic Trick', file: 'v_magic_trick.png' },
|
||||
v_money_tree: { name: 'Money Tree', file: 'v_money_tree.png' },
|
||||
v_nacho_tong: { name: 'Nacho Tong', file: 'v_nacho_tong.png' },
|
||||
v_observatory: { name: 'Observatory', file: 'v_observatory.png' },
|
||||
v_omen_globe: { name: 'Omen Globe', file: 'v_omen_globe.png' },
|
||||
v_overstock_norm: { name: 'Overstock', file: 'v_overstock_norm.png' },
|
||||
v_overstock_plus: { name: 'Overstock Plus', file: 'v_overstock_plus.png' },
|
||||
v_paint_brush: { name: 'Paint Brush', file: 'v_paint_brush.png' },
|
||||
v_palette: { name: 'Palette', file: 'v_palette.png' },
|
||||
v_petroglyph: { name: 'Petroglyph', file: 'v_petroglyph.png' },
|
||||
v_planet_merchant: { name: 'Planet Merchant', file: 'v_planet_merchant.png' },
|
||||
v_planet_tycoon: { name: 'Planet Tycoon', file: 'v_planet_tycoon.png' },
|
||||
v_recyclomancy: { name: 'Recyclomancy', file: 'v_recyclomancy.png' },
|
||||
v_reroll_glut: { name: 'Reroll Glut', file: 'v_reroll_glut.png' },
|
||||
v_reroll_surplus: { name: 'Reroll Surplus', file: 'v_reroll_surplus.png' },
|
||||
v_retcon: { name: 'Retcon', file: 'v_retcon.png' },
|
||||
v_seed_money: { name: 'Seed Money', file: 'v_seed_money.png' },
|
||||
v_tarot_merchant: { name: 'Tarot Merchant', file: 'v_tarot_merchant.png' },
|
||||
v_tarot_tycoon: { name: 'Tarot Tycoon', file: 'v_tarot_tycoon.png' },
|
||||
v_telescope: { name: 'Telescope', file: 'v_telescope.png' },
|
||||
v_undiscovered: { name: 'Locked', file: 'v_undiscovered.png' },
|
||||
v_wasteful: { name: 'Wasteful', file: 'v_wasteful.png' },
|
||||
}
|
||||
118
test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { db } from '@/server/db'
|
||||
import { player_games } from '@/server/db/schema'
|
||||
import { desc, eq } from 'drizzle-orm'
|
||||
import { Redis } from 'ioredis'
|
||||
const redisClient = new Redis(process.env.REDIS_URL as string)
|
||||
|
||||
type PlayerState = {
|
||||
status: string
|
||||
currentMatch?: {
|
||||
opponentId: string
|
||||
startTime: number
|
||||
}
|
||||
}
|
||||
|
||||
type StatusCount = {
|
||||
[key: string]: number
|
||||
}
|
||||
|
||||
type QueueEntry = {
|
||||
key: string
|
||||
value: PlayerState
|
||||
}
|
||||
|
||||
async function findQueueingPlayers(redis: Redis): Promise<QueueEntry[]> {
|
||||
try {
|
||||
const queueingPlayers: QueueEntry[] = []
|
||||
let cursor = '0'
|
||||
const pattern = 'player:*:state'
|
||||
|
||||
do {
|
||||
const [newCursor, keys] = await redis.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
pattern,
|
||||
'COUNT',
|
||||
'1000'
|
||||
)
|
||||
|
||||
cursor = newCursor
|
||||
|
||||
if (keys.length > 0) {
|
||||
const values = await redis.mget(keys)
|
||||
|
||||
keys.forEach((key, index) => {
|
||||
const value = values[index]
|
||||
if (!value) return
|
||||
|
||||
try {
|
||||
const state = JSON.parse(value) as PlayerState
|
||||
if (state.status === 'queuing') {
|
||||
queueingPlayers.push({
|
||||
key,
|
||||
value: state,
|
||||
})
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse player state:', parseError)
|
||||
console.error('Problematic value:', value)
|
||||
}
|
||||
})
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
return queueingPlayers
|
||||
} catch (error) {
|
||||
console.error('Failed to find queuing players:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
redisClient.on('connect', () => {
|
||||
console.log('Redis client connected')
|
||||
})
|
||||
|
||||
redisClient.on('error', (err) => {
|
||||
console.error('Redis client error:', err)
|
||||
})
|
||||
|
||||
async function logQueueingPlayers() {
|
||||
try {
|
||||
const queueingPlayers = await findQueueingPlayers(redisClient)
|
||||
console.log('Found', queueingPlayers.length, 'queuing players:')
|
||||
console.log(queueingPlayers)
|
||||
|
||||
return await Promise.all(
|
||||
queueingPlayers.map(async (player) => {
|
||||
const lastGame = await db
|
||||
.select()
|
||||
.from(player_games)
|
||||
.where(
|
||||
eq(
|
||||
player_games.playerId,
|
||||
player.key.replace('player:', '').replace(':state', '')
|
||||
)
|
||||
)
|
||||
.orderBy(desc(player_games.gameTime))
|
||||
.limit(1)
|
||||
return lastGame[0]
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to log queuing players:', error)
|
||||
throw error
|
||||
} finally {
|
||||
await redisClient.quit()
|
||||
}
|
||||
}
|
||||
|
||||
logQueueingPlayers()
|
||||
.then((e) => {
|
||||
for (const p of e)
|
||||
console.log('--', p?.playerName, Math.round(p?.playerMmr ?? 0), 'MMR')
|
||||
process.exit(0)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Unhandled error in logQueueingPlayers:', error)
|
||||
process.exit(1)
|
||||
})
|
||||