Files
kickkingsapi/matches.ts
T
2026-05-04 08:50:50 +00:00

452 lines
17 KiB
TypeScript

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';
/** Per player on the match: deducted when a winner is recorded. */
export const MATCH_ENTRY_FEE_RC = 21;
/** Added to the winning player's RC (after entry fee). */
const MATCH_WINNER_RC_BONUS = 34;
/** Added to each player's CC when a winner is recorded. */
const MATCH_PARTICIPANT_CC_REWARD = 100;
function coerceId(id: unknown): number | null {
if (id == null) return null;
if (typeof id === 'bigint') return Number(id);
if (typeof id === 'number' && Number.isFinite(id)) return id;
if (typeof id === 'string') {
const n = parseInt(id, 10);
return Number.isNaN(n) ? null : n;
}
return null;
}
/** Insert a match row when a new room is created. First player in the room = red (if provided). */
export async function insertMatchForNewRoom(settings: Settings, userRedId?: number): Promise<number | null> {
if (!supabaseConfigured(settings)) return null;
const supabase = createSupabase(settings);
const base = { entry_fee: MATCH_ENTRY_FEE_RC };
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 null;
}
return coerceId(data.id);
}
/** 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;
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 };
}
/**
* Entry fee from each participant, winner bonus RC, CC for both. Idempotent callers should set winner only once.
*/
async function applyMatchWinnerEconomy(
supabase: SupabaseClient,
userRed: number | null,
userBlue: number | null,
winnerId: number
): Promise<string | null> {
const ids = [...new Set([userRed, userBlue].filter((x): x is number => x != null && x >= 1))];
if (ids.length === 0) return null;
const { data: rows, error: selErr } = await supabase.from('users').select('id, rc, cc').in('id', ids);
if (selErr) return selErr.message;
if (!rows || rows.length !== ids.length) return 'One or more players not found in users';
for (const row of rows) {
const uid = coerceId((row as { id: unknown }).id);
if (uid == null) continue;
const baseRc = Number((row as { rc: unknown }).rc ?? 0);
const baseCc = Number((row as { cc: unknown }).cc ?? 0);
let newRc = baseRc - MATCH_ENTRY_FEE_RC;
if (uid === winnerId) newRc += MATCH_WINNER_RC_BONUS;
const newCc = baseCc + MATCH_PARTICIPANT_CC_REWARD;
const { error: upErr } = await supabase.from('users').update({ rc: newRc, cc: newCc }).eq('id', uid);
if (upErr) return upErr.message;
}
return null;
}
/** Second player joining the room = blue. */
export async function updateMatchUserBlue(settings: Settings, matchId: number, userBlueId: number): Promise<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')
.eq('id', matchId)
.maybeSingle();
if (fetchErr) {
res.status(500).json({ ok: false, error: fetchErr.message });
return;
}
if (!row) {
res.status(404).json({ ok: false, error: 'Match not found' });
return;
}
const userRed = coerceId((row as { user_red: unknown }).user_red);
const userBlue = coerceId((row as { user_blue: unknown }).user_blue);
const winnerId = side === 'red' ? userRed : userBlue;
if (winnerId == null) {
res.status(400).json({ ok: false, error: `${side} slot has no user on this match` });
return;
}
const { data: updated, error: updErr } = await supabase
.from('matches')
.update({ winner_id: winnerId })
.eq('id', matchId)
.is('winner_id', null)
.select('id')
.maybeSingle();
if (updErr) {
res.status(500).json({ ok: false, error: updErr.message });
return;
}
if (!updated) {
const { data: existing } = await supabase.from('matches').select('id').eq('id', matchId).maybeSingle();
if (!existing) {
res.status(404).json({ ok: false, error: 'Match not found' });
return;
}
res.status(409).json({ ok: false, error: 'Winner already set for this match' });
return;
}
const payErr = await applyMatchWinnerEconomy(supabase, userRed, userBlue, winnerId);
if (payErr != null) {
console.error('matches: economy after winner failed', matchId, payErr);
await supabase.from('matches').update({ winner_id: null }).eq('id', matchId).eq('winner_id', winnerId);
res.status(500).json({ ok: false, error: `Economy update failed: ${payErr}` });
return;
}
res.json({
ok: true,
id: matchId,
winner_id: winnerId,
economy: {
entry_fee_rc: MATCH_ENTRY_FEE_RC,
winner_rc_bonus: MATCH_WINNER_RC_BONUS,
participant_cc: MATCH_PARTICIPANT_CC_REWARD,
},
});
});
}