import express, { Express, Request, Response } from 'express'; import * as crypto from 'crypto'; import type { SupabaseClient } from '@supabase/supabase-js'; import { Room, Settings } from './types'; import { createSupabase, supabaseConfigured } from './auth_bridge'; import { logInfo, recordHistory } from './server_log'; /** Map Postgres RPC exception text to an HTTP-friendly error. */ function mapEconomyRpcError(message: string): { status: number; error: string } { const m = message.toLowerCase(); if (m.includes('insufficient_rc_red') || m.includes('insufficient_rc_blue')) { return { status: 402, error: 'One or both players have insufficient RC for the entry fee' }; } if (m.includes('insufficient_rc')) { return { status: 402, error: 'Insufficient RC for entry fee' }; } if (m.includes('joins_incomplete')) { return { status: 409, error: 'Both players must have joined before collecting entry fees' }; } if (m.includes('players_incomplete')) { return { status: 409, error: 'Match is missing red or blue player' }; } if (m.includes('entries_not_collected')) { return { status: 409, error: 'Entry fees not collected yet; both players must join first' }; } if (m.includes('winner_already_set') || m.includes('match_already_settled')) { return { status: 409, error: 'Winner already set for this match' }; } if (m.includes('winner_not_participant')) { return { status: 400, error: 'Winner must be red or blue on this match' }; } if (m.includes('match_not_found')) { return { status: 404, error: 'Match not found' }; } if (m.includes('escrow_balance_mismatch') || m.includes('escrow_not_zero') || m.includes('insufficient_escrow')) { return { status: 500, error: 'Match escrow balance is inconsistent' }; } if ( m.includes('could not find the function') || m.includes('schema cache') || (m.includes('function') && (m.includes('collect_match_entries') || m.includes('settle_match_reward'))) ) { return { status: 503, error: 'Economy RPCs not installed — apply schemas/rpc_collect_match_entries.sql and schemas/rpc_settle_match_reward.sql', }; } return { status: 500, error: message }; } function rpcErrorMessage(err: { message?: string } | null): string { return err?.message?.trim() || 'Unknown RPC error'; } /** Fallback when `public.settings` has no `entry_fee` row or value is invalid. */ export const MATCH_ENTRY_FEE_RC = 21; const SETTINGS_KEY_ENTRY_FEE = 'entry_fee'; const SETTINGS_KEY_BET_FEE = 'bet_fee'; /** Fallback when `public.settings` has no `bet_fee` row or value is invalid (percent applied to each player's entry). */ export const DEFAULT_BET_FEE_PERCENT = 10; /** RC entry fee for normal matchmaking (not rematches): `public.settings` row `key = entry_fee`, `value` integer string. */ export async function getDefaultMatchEntryFeeRc(settings: Settings): Promise { if (!supabaseConfigured(settings)) return MATCH_ENTRY_FEE_RC; const supabase = createSupabase(settings); const { data, error } = await supabase .from('settings') .select('value') .eq('key', SETTINGS_KEY_ENTRY_FEE) .maybeSingle(); if (error || !data || typeof (data as { value?: unknown }).value !== 'string') { return MATCH_ENTRY_FEE_RC; } const n = parseInt(String((data as { value: string }).value).trim(), 10); if (!Number.isInteger(n) || n < 0) return MATCH_ENTRY_FEE_RC; return n; } /** All rows from `public.settings` as key → value strings. */ export async function getSettingsTableDict(settings: Settings): Promise> { if (!supabaseConfigured(settings)) return {}; const supabase = createSupabase(settings); const { data, error } = await supabase.from('settings').select('key, value'); if (error || !data) return {}; const out: Record = {}; for (const row of data as { key?: unknown; value?: unknown }[]) { if (typeof row.key === 'string' && typeof row.value === 'string') { out[row.key] = row.value; } } return out; } /** Server fee percent of each player's entry (`bet_fee`): `public.settings` row `key = bet_fee`, `value` integer 0–100. */ export async function getBetFeePercentFromSettings(settings: Settings): Promise { if (!supabaseConfigured(settings)) return DEFAULT_BET_FEE_PERCENT; const supabase = createSupabase(settings); const { data, error } = await supabase .from('settings') .select('value') .eq('key', SETTINGS_KEY_BET_FEE) .maybeSingle(); if (error || !data || typeof (data as { value?: unknown }).value !== 'string') { return DEFAULT_BET_FEE_PERCENT; } const n = parseInt(String((data as { value: string }).value).trim(), 10); if (!Number.isInteger(n) || n < 0 || n > 100) return DEFAULT_BET_FEE_PERCENT; return n; } export type InsertMatchForNewRoomResult = { matchId: number | null; /** Same RC fee stored on `matches.entry_fee` and returned for in-memory rooms. */ entry_fee: number; }; /** * Winner's net RC = both entries minus per-player fees. * `fee_each = ceil(entry_fee * betFeePercent / 100)`, prize = `(entry_fee - fee_each) * 2`. * Collect moves both entries into the match escrow; settle pays prize from escrow and house fee to system. */ export function computeRcPrizeFromEntryFee(entryFee: number, betFeePercent: number = DEFAULT_BET_FEE_PERCENT): number { const feeEach = Math.ceil((entryFee * betFeePercent) / 100); return entryFee * 2 - feeEach * 2; } /** Added to each player's CC when a winner is recorded (default if `matches.prize_cc` is null). */ 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 { const entry_fee = await getDefaultMatchEntryFeeRc(settings); if (!supabaseConfigured(settings)) { return { matchId: null, entry_fee }; } const supabase = createSupabase(settings); const base = { entry_fee }; const payload = userRedId == null ? base : { ...base, 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 { matchId: null, entry_fee }; } return { matchId: coerceId(data.id), entry_fee }; } /** Dedicated server: rematch row with both players and custom `entry_fee` (RC). */ export async function insertRematchMatch( settings: Settings, userRedId: number, userBlueId: number, entryFee: number ): Promise { if (!supabaseConfigured(settings)) return null; const supabase = createSupabase(settings); const { data, error } = await supabase .from('matches') .insert({ user_red: userRedId, user_blue: userBlueId, entry_fee: entryFee, }) .select('id') .single(); if (error || !data) { console.error('matches: rematch insert failed', error?.message); return null; } return coerceId(data.id); } /** Load `username` for two user ids (order not preserved in result). */ export async function loadUsernamesForUserPair( settings: Settings, userA: number, userB: number ): Promise<{ nameById: Map } | null> { if (!supabaseConfigured(settings)) return null; const supabase = createSupabase(settings); const { data, error } = await supabase.from('users').select('id, username').in('id', [userA, userB]); if (error || !data || data.length !== 2) { if (error) console.error('matches: load usernames failed', error.message); return null; } const nameById = new Map(); for (const row of data as { id: unknown; username: unknown }[]) { const id = coerceId(row.id); if (id == null || typeof row.username !== 'string' || row.username.length < 1) return null; nameById.set(id, row.username); } if (!nameById.has(userA) || !nameById.has(userB)) return null; return { nameById }; } /** Same JSON shape for GET / (room found) and POST /internal/rematch success. */ export type MatchRoomSuccessEnvelope = { ok: true; entry_fee: number; rc_prize: number; for_red: Room | null; for_blue: Room | null; }; export type CreateRematchRoomResult = MatchRoomSuccessEnvelope | { ok: false; status: number; error: string }; export type CreateRematchRoomFn = (args: { userRedId: number; userBlueId: number; entryFee: number; }) => Promise; /** 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 }; } /** All-time completed match stats for a player (matches with a recorded winner). */ export async function getPlayerMatchStats( settings: Settings, playerId: number ): Promise<{ matches_played: number; wins: number; win_ratio: number }> { if (!supabaseConfigured(settings)) { return { matches_played: 0, wins: 0, win_ratio: 0 }; } const supabase = createSupabase(settings); const { data, error } = await supabase .from('matches') .select('winner_id') .or(`user_red.eq.${playerId},user_blue.eq.${playerId}`) .not('winner_id', 'is', null); if (error || !data) { console.error('matches: profile stats fetch failed', error?.message); return { matches_played: 0, wins: 0, win_ratio: 0 }; } let wins = 0; for (const row of data as { winner_id: unknown }[]) { if (coerceId(row.winner_id) === playerId) wins++; } const matches_played = data.length; const win_ratio = matches_played > 0 ? wins / matches_played : 0; return { matches_played, wins, win_ratio }; } function daysSinceRegistration(createdAt: string): number { const created = new Date(createdAt).getTime(); if (Number.isNaN(created)) return 0; return Math.floor((Date.now() - created) / (1000 * 60 * 60 * 24)); } function coerceNonNegativeInt(v: unknown): number | null { if (v == null) return null; if (typeof v === 'bigint') { const n = Number(v); return Number.isSafeInteger(n) && n >= 0 ? n : null; } if (typeof v === 'number' && Number.isFinite(v)) { const t = Math.trunc(v); return t >= 0 ? t : null; } if (typeof v === 'string' && /^-?\d+$/.test(v.trim())) { const n = parseInt(v.trim(), 10); return Number.isNaN(n) || n < 0 ? null : n; } return null; } /** * Move both entry fees into the per-match escrow when both players have joined. * Idempotent via `matches.entries_collected_at`. */ async function callCollectMatchEntries( supabase: SupabaseClient, matchId: number ): Promise<{ ok: true; data: Record } | { ok: false; status: number; error: string }> { const { data, error } = await supabase.rpc('collect_match_entries', { p_match_id: matchId }); if (error) { return { ok: false, ...mapEconomyRpcError(rpcErrorMessage(error)) }; } const payload = (data && typeof data === 'object' ? data : {}) as Record; return { ok: true, data: payload }; } /** * Pay prize + house fee only from match escrow; set winner_id + settled_at atomically. * Idempotent if already settled for the same winner. */ async function callSettleMatchReward( supabase: SupabaseClient, matchId: number, winnerId: number ): Promise<{ ok: true; data: Record } | { ok: false; status: number; error: string }> { const { data, error } = await supabase.rpc('settle_match_reward', { p_match_id: matchId, p_winner_id: winnerId, }); if (error) { return { ok: false, ...mapEconomyRpcError(rpcErrorMessage(error)) }; } const payload = (data && typeof data === 'object' ? data : {}) as Record; return { ok: true, data: payload }; } /** Second player joining the room = blue. No-op if blue is already set. */ export async function updateMatchUserBlue(settings: Settings, matchId: number, userBlueId: number): Promise { if (!supabaseConfigured(settings)) return; const supabase = createSupabase(settings); const { data: row, error: fetchErr } = await supabase .from('matches') .select('user_blue') .eq('id', matchId) .maybeSingle(); if (fetchErr) { console.error('matches: update user_blue fetch failed', fetchErr.message); return; } if (!row) { console.error('matches: update user_blue match not found', matchId); return; } const existingBlue = coerceId((row as { user_blue: unknown }).user_blue); if (existingBlue != null) { if (existingBlue !== userBlueId) { logInfo('[update-user-blue] skip overwrite', { match_id: matchId, existing_user_blue: existingBlue, attempted_user_blue: userBlueId, }); recordHistory( `Skip user_blue overwrite for match ${matchId}: already ${existingBlue}, not ${userBlueId}` ); } return; } const { error } = await supabase .from('matches') .update({ user_blue: userBlueId }) .eq('id', matchId) .is('user_blue', null); 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, deps?: { createRematchRoom?: CreateRematchRoomFn } ): 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, }); }); /** Public: RC balance for a player (`users.id` / matchmaking UserId). */ app.get('/players/:playerId/rc', 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 supabase = createSupabase(settings); const { data, error } = await supabase.from('users').select('rc').eq('id', playerId).maybeSingle(); if (error) { res.status(500).json({ ok: false, error: error.message }); return; } if (!data) { res.status(404).json({ ok: false, error: 'Player not found' }); return; } const rc = Number((data as { rc: unknown }).rc ?? 0); res.json({ ok: true, player_id: playerId, rc }); }); /** Public: profile info for a player (`users.id` / matchmaking UserId). */ app.get('/players/:playerId/profile', 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 supabase = createSupabase(settings); const [userResult, stats] = await Promise.all([ supabase.from('users').select('id, username, email, created_at').eq('id', playerId).maybeSingle(), getPlayerMatchStats(settings, playerId), ]); if (userResult.error) { res.status(500).json({ ok: false, error: userResult.error.message }); return; } if (!userResult.data) { res.status(404).json({ ok: false, error: 'Player not found' }); return; } const user = userResult.data as { username: unknown; email: unknown; created_at: unknown }; const createdAt = typeof user.created_at === 'string' ? user.created_at : String(user.created_at ?? ''); res.json({ ok: true, player_id: playerId, username: typeof user.username === 'string' ? user.username : '', email: typeof user.email === 'string' ? user.email : null, days_since_registration: daysSinceRegistration(createdAt), matches_played: stats.matches_played, win_ratio: stats.win_ratio, }); }); /** * Dedicated server only: open a room with two players and a custom RC entry fee. * Response body matches GET / on room success: `{ ok, entry_fee, for_red, for_blue }`. */ app.post('/internal/rematch', jsonParser, async (req: Request, res: Response) => { if (rejectUnlessDedicatedServer(req, res, settings)) return; if (!deps?.createRematchRoom) { res.status(503).json({ ok: false, error: 'Rematch handler not configured' }); return; } const body = req.body as Record; const userRedId = coerceId(body.user_red_id); const userBlueId = coerceId(body.user_blue_id); const rawFee = body.entry_fee; const entryFee = typeof rawFee === 'bigint' ? Number(rawFee) : typeof rawFee === 'number' && Number.isFinite(rawFee) ? Math.trunc(rawFee) : typeof rawFee === 'string' && /^-?\d+$/.test(rawFee.trim()) ? parseInt(rawFee.trim(), 10) : null; if (userRedId == null || userRedId < 1 || userBlueId == null || userBlueId < 1) { res.status(400).json({ ok: false, error: 'user_red_id and user_blue_id must be positive integers' }); return; } if (userRedId === userBlueId) { res.status(400).json({ ok: false, error: 'user_red_id and user_blue_id must differ' }); return; } if (entryFee == null || !Number.isInteger(entryFee) || entryFee < 0) { res.status(400).json({ ok: false, error: 'entry_fee must be a non-negative integer' }); return; } const result = await deps.createRematchRoom({ userRedId, userBlueId, entryFee }); if (!result.ok) { res.status(result.status).json({ ok: false, error: result.error }); return; } res.json(result); }); 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, red_joined_at, blue_joined_at, entries_collected_at') .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; } const row = data as { red_joined_at: unknown; blue_joined_at: unknown; entries_collected_at: unknown; }; const bothJoined = row.red_joined_at != null && row.blue_joined_at != null; let entriesCollected = row.entries_collected_at != null; if (bothJoined && !entriesCollected) { logInfo('[collect-entries] start', { match_id: matchId }); const collect = await callCollectMatchEntries(supabase, matchId); if (!collect.ok) { logInfo('[collect-entries] failed', { match_id: matchId, status: collect.status, error: collect.error, }); recordHistory(`Fee collect failed for match ${matchId}: ${collect.error}`); res.status(collect.status).json({ ok: false, error: collect.error, id: matchId, entries_collected: false, }); return; } const alreadyCollected = collect.data.already_collected === true; const escrowUserId = coerceId(collect.data.escrow_user_id); logInfo('[collect-entries] success', { match_id: matchId, already_collected: alreadyCollected, escrow_user_id: escrowUserId, entry_fee: collect.data.entry_fee, pot_rc: collect.data.pot_rc, fee_each: collect.data.fee_each, }); recordHistory( alreadyCollected ? `Fee collect already done for match ${matchId} (escrow ${escrowUserId ?? '—'})` : `Fee collect for match ${matchId}: entry_fee ${String(collect.data.entry_fee ?? '—')} pot ${String(collect.data.pot_rc ?? '—')} escrow ${escrowUserId ?? '—'}` ); entriesCollected = true; res.json({ ok: true, id: matchId, entries_collected: true, already_collected: alreadyCollected, escrow_user_id: escrowUserId, }); return; } res.json({ ok: true, id: matchId, entries_collected: entriesCollected, }); }); /** Unity dedicated server: set winner and settle prize from match escrow only. */ 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, entry_fee, prize_cc, entries_collected_at, settled_at, winner_id') .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 entryFeeRaw = coerceNonNegativeInt((row as { entry_fee: unknown }).entry_fee); const entryFee = entryFeeRaw ?? MATCH_ENTRY_FEE_RC; const prizeCcRaw = coerceNonNegativeInt((row as { prize_cc: unknown }).prize_cc); const prizeCc = prizeCcRaw ?? MATCH_PARTICIPANT_CC_REWARD; 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 existingWinner = coerceId((row as { winner_id: unknown }).winner_id); if (existingWinner != null) { if (existingWinner === winnerId && (row as { settled_at: unknown }).settled_at != null) { const betFeePercent = await getBetFeePercentFromSettings(settings); const rcPrize = computeRcPrizeFromEntryFee(entryFee, betFeePercent); logInfo('[settle-reward] already settled', { match_id: matchId, winner: side, winner_id: winnerId, entry_fee_rc: entryFee, rc_prize: rcPrize, participant_cc: prizeCc, }); recordHistory(`Reward already settled for match ${matchId} winner ${winnerId} (${side})`); res.json({ ok: true, id: matchId, winner_id: winnerId, already_settled: true, economy: { entry_fee_rc: entryFee, rc_prize: rcPrize, participant_cc: prizeCc, }, }); return; } logInfo('[settle-reward] winner already set', { match_id: matchId, requested_winner: side, requested_winner_id: winnerId, existing_winner_id: existingWinner, }); recordHistory( `Reward settle rejected for match ${matchId}: winner already set (${existingWinner})` ); res.status(409).json({ ok: false, error: 'Winner already set for this match' }); return; } if ((row as { entries_collected_at: unknown }).entries_collected_at == null) { logInfo('[settle-reward] entries not collected', { match_id: matchId, winner: side, winner_id: winnerId, }); recordHistory(`Reward settle rejected for match ${matchId}: entry fees not collected`); res.status(409).json({ ok: false, error: 'Entry fees not collected yet; both players must join first', }); return; } logInfo('[settle-reward] start', { match_id: matchId, winner: side, winner_id: winnerId, user_red: userRed, user_blue: userBlue, entry_fee_rc: entryFee, }); const settle = await callSettleMatchReward(supabase, matchId, winnerId); if (!settle.ok) { logInfo('[settle-reward] failed', { match_id: matchId, winner: side, winner_id: winnerId, status: settle.status, error: settle.error, }); recordHistory(`Reward settle failed for match ${matchId} winner ${winnerId}: ${settle.error}`); res.status(settle.status).json({ ok: false, error: settle.error }); return; } const betFeePercent = await getBetFeePercentFromSettings(settings); const rcPrizeFromRpc = coerceNonNegativeInt(settle.data.rc_prize); const participantCcFromRpc = coerceNonNegativeInt(settle.data.participant_cc); const rcPrize = rcPrizeFromRpc ?? computeRcPrizeFromEntryFee(entryFee, betFeePercent); const participantCc = participantCcFromRpc ?? prizeCc; const alreadySettled = settle.data.already_settled === true; logInfo('[settle-reward] success', { match_id: matchId, winner: side, winner_id: winnerId, already_settled: alreadySettled, entry_fee_rc: entryFee, rc_prize: rcPrize, house_fee: settle.data.house_fee, participant_cc: participantCc, }); recordHistory( alreadySettled ? `Reward already settled for match ${matchId} winner ${winnerId} (${side})` : `Reward settle for match ${matchId}: winner ${winnerId} (${side}) prize ${rcPrize} house ${String(settle.data.house_fee ?? '—')} cc ${participantCc}` ); res.json({ ok: true, id: matchId, winner_id: winnerId, already_settled: alreadySettled, economy: { entry_fee_rc: entryFee, rc_prize: rcPrize, participant_cc: participantCc, }, }); }); }