import express, { Express, Request, Response } from 'express'; import * as crypto from 'crypto'; import type { SupabaseClient } from '@supabase/supabase-js'; import { Settings } from './types'; import { createSupabase, supabaseConfigured } from './auth_bridge'; /** Per player on the match: deducted when a winner is recorded. */ const MATCH_ENTRY_FEE_RC = 21; /** Added to the winning player's RC (after entry fee). */ const MATCH_WINNER_RC_BONUS = 34; /** Added to each player's CC when a winner is recorded. */ const MATCH_PARTICIPANT_CC_REWARD = 100; function coerceId(id: unknown): number | null { if (id == null) return null; if (typeof id === 'bigint') return Number(id); if (typeof id === 'number' && Number.isFinite(id)) return id; if (typeof id === 'string') { const n = parseInt(id, 10); return Number.isNaN(n) ? null : n; } return null; } /** Insert a match row when a new room is created. First player in the room = red (if provided). */ export async function insertMatchForNewRoom(settings: Settings, userRedId?: number): Promise { if (!supabaseConfigured(settings)) return null; const supabase = createSupabase(settings); const payload = userRedId == null ? {} : { user_red: userRedId }; const { data, error } = await supabase.from('matches').insert(payload).select('id').single(); if (error || !data) { console.error('matches: insert failed', error?.message); return null; } return coerceId(data.id); } /** Latest 10 matches for this user (by created_at); counts wins/losses from winner_id only. */ export async function getPlayerLast10MatchRecord( settings: Settings, playerId: number ): Promise<{ l10_wins: number; l10_losses: number }> { if (!supabaseConfigured(settings)) { return { l10_wins: 0, l10_losses: 0 }; } const supabase = createSupabase(settings); const { data, error } = await supabase .from('matches') .select('winner_id') .or(`user_red.eq.${playerId},user_blue.eq.${playerId}`) .order('created_at', { ascending: false }) .limit(10); if (error || !data) { console.error('matches: last10 fetch failed', error?.message); return { l10_wins: 0, l10_losses: 0 }; } let l10_wins = 0; let l10_losses = 0; for (const row of data as { winner_id: unknown }[]) { const wid = coerceId(row.winner_id); if (wid == null) continue; if (wid === playerId) l10_wins++; else l10_losses++; } return { l10_wins, l10_losses }; } /** * Entry fee from each participant, winner bonus RC, CC for both. Idempotent callers should set winner only once. */ async function applyMatchWinnerEconomy( supabase: SupabaseClient, userRed: number | null, userBlue: number | null, winnerId: number ): Promise { const ids = [...new Set([userRed, userBlue].filter((x): x is number => x != null && x >= 1))]; if (ids.length === 0) return null; const { data: rows, error: selErr } = await supabase.from('users').select('id, rc, cc').in('id', ids); if (selErr) return selErr.message; if (!rows || rows.length !== ids.length) return 'One or more players not found in users'; for (const row of rows) { const uid = coerceId((row as { id: unknown }).id); if (uid == null) continue; const baseRc = Number((row as { rc: unknown }).rc ?? 0); const baseCc = Number((row as { cc: unknown }).cc ?? 0); let newRc = baseRc - MATCH_ENTRY_FEE_RC; if (uid === winnerId) newRc += MATCH_WINNER_RC_BONUS; const newCc = baseCc + MATCH_PARTICIPANT_CC_REWARD; const { error: upErr } = await supabase.from('users').update({ rc: newRc, cc: newCc }).eq('id', uid); if (upErr) return upErr.message; } return null; } /** Second player joining the room = blue. */ export async function updateMatchUserBlue(settings: Settings, matchId: number, userBlueId: number): Promise { if (!supabaseConfigured(settings)) return; const supabase = createSupabase(settings); const { error } = await supabase.from('matches').update({ user_blue: userBlueId }).eq('id', matchId); if (error) { console.error('matches: update user_blue failed', error.message); } } function dedicatedServerConfigured(settings: Settings): boolean { return Boolean(settings.dedicated_server_secret?.trim()); } function timingSafeEqualStr(a: string, b: string): boolean { try { const ba = Buffer.from(a, 'utf8'); const bb = Buffer.from(b, 'utf8'); if (ba.length !== bb.length) return false; return crypto.timingSafeEqual(ba, bb); } catch { return false; } } /** If true, response already sent — handler must return. */ function rejectUnlessDedicatedServer(req: Request, res: Response, settings: Settings): boolean { if (!supabaseConfigured(settings)) { res.status(503).json({ ok: false, error: 'Database not configured' }); return true; } if (!dedicatedServerConfigured(settings)) { res.status(503).json({ ok: false, error: 'dedicated_server_secret not set in settings' }); return true; } const hdr = req.headers['x-dedicated-server-secret']; const expected = settings.dedicated_server_secret!.trim(); if (typeof hdr !== 'string' || !timingSafeEqualStr(hdr, expected)) { res.status(403).json({ ok: false, error: 'Forbidden' }); return true; } return false; } export function registerMatchRoutes(app: Express, settings: Settings): void { const jsonParser = express.json(); /** * Public: last-10 match record for a player (`users.id` / matchmaking UserId). * Wins/losses from the latest 10 `matches` rows where the player is red or blue. */ app.get('/players/:playerId/l10', async (req: Request, res: Response) => { if (!supabaseConfigured(settings)) { res.status(503).json({ ok: false, error: 'Database not configured' }); return; } const playerId = coerceId(req.params.playerId); if (playerId == null || playerId < 1) { res.status(400).json({ ok: false, error: 'Invalid player id' }); return; } const l10 = await getPlayerLast10MatchRecord(settings, playerId); res.json({ ok: true, player_id: playerId, l10_wins: l10.l10_wins, l10_losses: l10.l10_losses, }); }); app.patch('/internal/match/:matchId', jsonParser, async (req: Request, res: Response) => { if (rejectUnlessDedicatedServer(req, res, settings)) return; const matchId = coerceId(req.params.matchId); if (matchId == null || matchId < 1) { res.status(400).json({ ok: false, error: 'Invalid match id' }); return; } const body = req.body as Record; const patch: Record = {}; if (Object.prototype.hasOwnProperty.call(body, 'red_joined_at')) { const v = body.red_joined_at; if (v === null) patch.red_joined_at = null; else if (typeof v === 'string') patch.red_joined_at = v; else { res.status(400).json({ ok: false, error: 'red_joined_at must be ISO string or null' }); return; } } if (Object.prototype.hasOwnProperty.call(body, 'blue_joined_at')) { const v = body.blue_joined_at; if (v === null) patch.blue_joined_at = null; else if (typeof v === 'string') patch.blue_joined_at = v; else { res.status(400).json({ ok: false, error: 'blue_joined_at must be ISO string or null' }); return; } } if (Object.prototype.hasOwnProperty.call(body, 'status')) { const v = body.status; if (v === null) patch.status = null; else if (typeof v === 'number' && Number.isInteger(v)) patch.status = v; else if (typeof v === 'string' && /^-?\d+$/.test(v)) patch.status = parseInt(v, 10); else { res.status(400).json({ ok: false, error: 'status must be integer or null' }); return; } } if (Object.keys(patch).length === 0) { res.status(400).json({ ok: false, error: 'Body must include at least one of red_joined_at, blue_joined_at, status' }); return; } const supabase = createSupabase(settings); const { data, error } = await supabase.from('matches').update(patch).eq('id', matchId).select('id').maybeSingle(); if (error) { res.status(500).json({ ok: false, error: error.message }); return; } if (!data) { res.status(404).json({ ok: false, error: 'Match not found' }); return; } res.json({ ok: true, id: matchId }); }); /** Unity dedicated server: set `winner_id` from match sides (`user_red` / `user_blue`). */ app.patch('/internal/match/:matchId/winner', jsonParser, async (req: Request, res: Response) => { if (rejectUnlessDedicatedServer(req, res, settings)) return; const matchId = coerceId(req.params.matchId); if (matchId == null || matchId < 1) { res.status(400).json({ ok: false, error: 'Invalid match id' }); return; } const body = req.body as Record; const side = body.winner; if (side !== 'red' && side !== 'blue') { res.status(400).json({ ok: false, error: 'Body must include winner: "red" | "blue"' }); return; } const supabase = createSupabase(settings); const { data: row, error: fetchErr } = await supabase .from('matches') .select('user_red, user_blue') .eq('id', matchId) .maybeSingle(); if (fetchErr) { res.status(500).json({ ok: false, error: fetchErr.message }); return; } if (!row) { res.status(404).json({ ok: false, error: 'Match not found' }); return; } const userRed = coerceId((row as { user_red: unknown }).user_red); const userBlue = coerceId((row as { user_blue: unknown }).user_blue); const winnerId = side === 'red' ? userRed : userBlue; if (winnerId == null) { res.status(400).json({ ok: false, error: `${side} slot has no user on this match` }); return; } const { data: updated, error: updErr } = await supabase .from('matches') .update({ winner_id: winnerId }) .eq('id', matchId) .is('winner_id', null) .select('id') .maybeSingle(); if (updErr) { res.status(500).json({ ok: false, error: updErr.message }); return; } if (!updated) { const { data: existing } = await supabase.from('matches').select('id').eq('id', matchId).maybeSingle(); if (!existing) { res.status(404).json({ ok: false, error: 'Match not found' }); return; } res.status(409).json({ ok: false, error: 'Winner already set for this match' }); return; } const payErr = await applyMatchWinnerEconomy(supabase, userRed, userBlue, winnerId); if (payErr != null) { console.error('matches: economy after winner failed', matchId, payErr); await supabase.from('matches').update({ winner_id: null }).eq('id', matchId).eq('winner_id', winnerId); res.status(500).json({ ok: false, error: `Economy update failed: ${payErr}` }); return; } res.json({ ok: true, id: matchId, winner_id: winnerId, economy: { entry_fee_rc: MATCH_ENTRY_FEE_RC, winner_rc_bonus: MATCH_WINNER_RC_BONUS, participant_cc: MATCH_PARTICIPANT_CC_REWARD, }, }); }); }