add vouchers display

This commit is contained in:
2025-07-12 12:37:09 +02:00
parent c54ce4bec1
commit 168c141d64
37 changed files with 336 additions and 3 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
public/cards/v_blank.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
public/cards/v_glow_up.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/cards/v_grabber.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
public/cards/v_hone.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
public/cards/v_illusion.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
public/cards/v_locked.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
public/cards/v_palette.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
public/cards/v_retcon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/cards/v_wasteful.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -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
View 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
View 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)
})