Files
kickkingsapi/matches.ts
T
2026-05-05 20:01:28 +00:00

551 lines
21 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';
/** 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 of prize pool taken as server fee). */
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;
}
/** Server fee percent of the prize pool (`entry_fee * 2`): `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;
};
/**
* `prize_pool = entry_fee * 2`, `server_fee = ceil(prize_pool * betFeePercent / 100)`, returns `prize_pool - server_fee`.
* `betFeePercent` comes from `public.settings` key `bet_fee` (default {@link DEFAULT_BET_FEE_PERCENT}).
*/
export function computeRcPrizeFromEntryFee(entryFee: number, betFeePercent: number = DEFAULT_BET_FEE_PERCENT): number {
const prizePool = entryFee * 2;
const serverFee = Math.ceil((prizePool * betFeePercent) / 100);
return prizePool - serverFee;
}
/** 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 };
}
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;
}
/**
* Each player pays `entry_fee` RC; winner receives RC prize after `betFeePercent` server cut; each gets `prizeCc` CC.
* Idempotent callers should set winner only once.
*/
async function applyMatchWinnerEconomy(
supabase: SupabaseClient,
userRed: number | null,
userBlue: number | null,
winnerId: number,
entryFee: number,
prizeCc: number,
betFeePercent: number
): Promise<string | null> {
const ids = [...new Set([userRed, userBlue].filter((x): x is number => x != null && x >= 1))];
if (ids.length === 0) return null;
const rcPrize = computeRcPrizeFromEntryFee(entryFee, betFeePercent);
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 - entryFee;
if (uid === winnerId) newRc += rcPrize;
const newCc = baseCc + prizeCc;
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<void> {
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,
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 });
});
/**
* 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').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<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')
.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 betFeePercent = await getBetFeePercentFromSettings(settings);
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,
entryFee,
prizeCc,
betFeePercent
);
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;
}
const rcPrize = computeRcPrizeFromEntryFee(entryFee, betFeePercent);
res.json({
ok: true,
id: matchId,
winner_id: winnerId,
economy: {
entry_fee_rc: entryFee,
rc_prize: rcPrize,
participant_cc: prizeCc,
},
});
});
}