Files
kickkingsapi/matches.ts
T
2026-08-14 20:55:11 +00:00

842 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number> {
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<Record<string, string>> {
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<string, string> = {};
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 0100. */
export async function getBetFeePercentFromSettings(settings: Settings): Promise<number> {
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<InsertMatchForNewRoomResult> {
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<number | null> {
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<number, string> } | 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<number, string>();
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<CreateRematchRoomResult>;
/** 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<string, unknown> } | { 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<string, unknown>;
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<string, unknown> } | { 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<string, unknown>;
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<void> {
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<string, unknown>;
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<string, unknown>;
const patch: Record<string, string | number | null> = {};
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<string, unknown>;
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,
},
});
});
}