Files
kickkingsapi/matches.ts
T
2026-09-04 19:11:49 +00:00

1045 lines
42 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') ||
m.includes('settle_race')
) {
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 connected, non-forfeiting 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>;
/** Drop the in-memory matchmaking room after winner settle (idempotent if already gone). */
export type OnMatchSettledFn = (matchId: number) => Promise<void>;
/** Mark the in-memory room live after both clients connected and fees collected. */
export type OnBothPlayersConnectedFn = (matchId: number) => Promise<void>;
/** 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));
}
/** Historical settles stored null; those matches awarded CC to both sides. */
function coerceStoredAwardFlag(v: unknown): boolean {
return v !== false;
}
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;
}
/** Dedicated-server round score for MMR; omitted/null → null (legacy 30 / 100%). */
function parseOptionalMatchScore(v: unknown): { ok: true; value: number | null } | { ok: false } {
if (v === undefined || v === null) return { ok: true, value: null };
const n = coerceNonNegativeInt(v);
if (n == null || n > 32767) return { ok: false };
return { ok: true, value: n };
}
function coerceOptionalBoolean(v: unknown): boolean | null {
return typeof v === 'boolean' ? v : null;
}
/** Map a settled `winner_id` back to the dedicated-server side label. */
function matchWinnerSide(
winnerId: number,
userRed: number | null,
userBlue: number | null
): 'red' | 'blue' | null {
if (userRed != null && winnerId === userRed) return 'red';
if (userBlue != null && winnerId === userBlue) return 'blue';
return null;
}
export type AdminMmrRow = { id: number; username: string; mmr: number };
const ESCROW_USERNAME_PREFIX = 'match_escrow_';
/** Hidden MMR leaderboard for admins. Excludes system id 1 and per-match escrow users. */
export async function getAdminMmrLeaderboard(
settings: Settings,
limit: number
): Promise<{ ok: true; players: AdminMmrRow[] } | { ok: false; error: string }> {
if (!supabaseConfigured(settings)) {
return { ok: false, error: 'Database not configured' };
}
const supabase = createSupabase(settings);
const { data, error } = await supabase
.from('users')
.select('id, username, mmr')
.gt('id', 1)
.not('username', 'like', `${ESCROW_USERNAME_PREFIX}%`)
.order('mmr', { ascending: false })
.limit(limit);
if (error) {
return { ok: false, error: error.message };
}
const players: AdminMmrRow[] = [];
for (const row of (data ?? []) as { id?: unknown; username?: unknown; mmr?: unknown }[]) {
const id = coerceId(row.id);
if (id == null || id < 2) continue;
const username = typeof row.username === 'string' ? row.username : '';
if (username.startsWith(ESCROW_USERNAME_PREFIX)) continue;
const mmrRaw = coerceNonNegativeInt(row.mmr);
players.push({ id, username, mmr: mmrRaw ?? 1000 });
if (players.length >= limit) break;
}
return { ok: true, players };
}
/**
* 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.
* `redConnected` / `blueConnected` null = omitted (award CC to that side, old dedicated servers).
* Forfeit (explicit or disconnect-inferred) never awards CC to the loser.
* Scores / forfeit drive hidden MMR (null = legacy: treat as 30 unless disconnect-inferred forfeit).
*/
async function callSettleMatchReward(
supabase: SupabaseClient,
matchId: number,
winnerId: number,
redConnected: boolean | null,
blueConnected: boolean | null,
redScore: number | null,
blueScore: number | null,
forfeit: boolean | null
): 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,
p_red_connected: redConnected,
p_blue_connected: blueConnected,
p_red_score: redScore,
p_blue_score: blueScore,
p_forfeit: forfeit,
});
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;
onMatchSettled?: OnMatchSettledFn;
onBothPlayersConnected?: OnBothPlayersConnectedFn;
}
): void {
const jsonParser = express.json();
const dropRoomAfterSettle = async (matchId: number): Promise<void> => {
if (!deps?.onMatchSettled) return;
try {
await deps.onMatchSettled(matchId);
} catch (err) {
console.error('Failed to drop matchmaking room after settle', { match_id: matchId, err });
}
};
const markBothConnected = async (matchId: number): Promise<void> => {
if (!deps?.onBothPlayersConnected) return;
try {
await deps.onBothPlayersConnected(matchId);
} catch (err) {
console.error('Failed to mark room both-connected after collect', { match_id: matchId, err });
}
};
/**
* 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;
await markBothConnected(matchId);
res.json({
ok: true,
id: matchId,
entries_collected: true,
already_collected: alreadyCollected,
escrow_user_id: escrowUserId,
});
return;
}
if (entriesCollected) {
await markBothConnected(matchId);
}
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;
}
// Both strict booleans → use them. Either omitted or non-boolean → null (award both).
const bothConnectedBools =
typeof body.red_connected === 'boolean' && typeof body.blue_connected === 'boolean';
const redConnected: boolean | null = bothConnectedBools ? (body.red_connected as boolean) : null;
const blueConnected: boolean | null = bothConnectedBools ? (body.blue_connected as boolean) : null;
const redScoreParsed = parseOptionalMatchScore(body.red_score);
const blueScoreParsed = parseOptionalMatchScore(body.blue_score);
if (!redScoreParsed.ok || !blueScoreParsed.ok) {
res.status(400).json({ ok: false, error: 'red_score and blue_score must be non-negative integers' });
return;
}
const redScore = redScoreParsed.value;
const blueScore = blueScoreParsed.value;
const forfeitFlag = coerceOptionalBoolean(body.forfeit);
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, cc_awarded_red, cc_awarded_blue, red_score, blue_score, forfeit, mmr_delta'
)
.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);
const storedForfeit =
typeof (row as { forfeit: unknown }).forfeit === 'boolean'
? (row as { forfeit: boolean }).forfeit
: null;
if (existingWinner != null) {
const settledAt = (row as { settled_at: unknown }).settled_at;
const sameWinnerAlreadySettled = existingWinner === winnerId && settledAt != null;
const dualForfeitAlreadySettled =
forfeitFlag === true && storedForfeit === true && settledAt != null;
if (sameWinnerAlreadySettled || dualForfeitAlreadySettled) {
const actualWinnerId = existingWinner;
const actualSide = matchWinnerSide(actualWinnerId, userRed, userBlue) ?? side;
const betFeePercent = await getBetFeePercentFromSettings(settings);
const rcPrize = computeRcPrizeFromEntryFee(entryFee, betFeePercent);
const ccAwardedRed = coerceStoredAwardFlag((row as { cc_awarded_red: unknown }).cc_awarded_red);
const ccAwardedBlue = coerceStoredAwardFlag((row as { cc_awarded_blue: unknown }).cc_awarded_blue);
const mmrDelta = coerceNonNegativeInt((row as { mmr_delta: unknown }).mmr_delta);
logInfo('[settle-reward] already settled', {
match_id: matchId,
winner: actualSide,
winner_id: actualWinnerId,
requested_winner: side,
requested_winner_id: winnerId,
entry_fee_rc: entryFee,
rc_prize: rcPrize,
participant_cc: prizeCc,
cc_awarded_red: ccAwardedRed,
cc_awarded_blue: ccAwardedBlue,
mmr_delta: mmrDelta,
forfeit: storedForfeit,
});
recordHistory(
`Reward already settled for match ${matchId} winner ${actualWinnerId} (${actualSide}) mmr_delta=${mmrDelta ?? '—'}`
);
await dropRoomAfterSettle(matchId);
res.json({
ok: true,
id: matchId,
winner: actualSide,
winner_id: actualWinnerId,
already_settled: true,
economy: {
entry_fee_rc: entryFee,
rc_prize: rcPrize,
participant_cc: prizeCc,
cc_awarded_red: ccAwardedRed,
cc_awarded_blue: ccAwardedBlue,
},
mmr_delta: mmrDelta,
forfeit: storedForfeit,
});
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,
red_connected: redConnected,
blue_connected: blueConnected,
red_score: redScore,
blue_score: blueScore,
forfeit: forfeitFlag,
});
const settle = await callSettleMatchReward(
supabase,
matchId,
winnerId,
redConnected,
blueConnected,
redScore,
blueScore,
forfeitFlag
);
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;
const settledWinnerId = coerceId(settle.data.winner_id) ?? winnerId;
const settledSide = matchWinnerSide(settledWinnerId, userRed, userBlue) ?? side;
const settleForfeit =
typeof settle.data.forfeit === 'boolean' ? settle.data.forfeit : forfeitFlag;
const ccAwardedRed =
typeof settle.data.cc_awarded_red === 'boolean'
? settle.data.cc_awarded_red
: settledSide === 'blue' && settleForfeit === true
? false
: (redConnected ?? true);
const ccAwardedBlue =
typeof settle.data.cc_awarded_blue === 'boolean'
? settle.data.cc_awarded_blue
: settledSide === 'red' && settleForfeit === true
? false
: (blueConnected ?? true);
const mmrDelta = coerceNonNegativeInt(settle.data.mmr_delta);
logInfo('[settle-reward] success', {
match_id: matchId,
winner: settledSide,
winner_id: settledWinnerId,
requested_winner: side,
requested_winner_id: winnerId,
already_settled: alreadySettled,
entry_fee_rc: entryFee,
rc_prize: rcPrize,
house_fee: settle.data.house_fee,
participant_cc: participantCc,
cc_awarded_red: ccAwardedRed,
cc_awarded_blue: ccAwardedBlue,
mmr_delta: mmrDelta,
forfeit: settleForfeit,
red_score: settle.data.red_score ?? redScore,
blue_score: settle.data.blue_score ?? blueScore,
});
recordHistory(
alreadySettled
? `Reward already settled for match ${matchId} winner ${settledWinnerId} (${settledSide}) mmr_delta=${mmrDelta ?? '—'}`
: `Reward settle for match ${matchId}: winner ${settledWinnerId} (${settledSide}) prize ${rcPrize} house ${String(settle.data.house_fee ?? '—')} cc ${participantCc} awarded red=${ccAwardedRed} blue=${ccAwardedBlue} mmr_delta=${mmrDelta ?? '—'} forfeit=${String(settleForfeit)}`
);
await dropRoomAfterSettle(matchId);
res.json({
ok: true,
id: matchId,
winner: settledSide,
winner_id: settledWinnerId,
already_settled: alreadySettled,
economy: {
entry_fee_rc: entryFee,
rc_prize: rcPrize,
participant_cc: participantCc,
cc_awarded_red: ccAwardedRed,
cc_awarded_blue: ccAwardedBlue,
},
mmr_delta: mmrDelta,
forfeit: settleForfeit,
});
});
}