842 lines
33 KiB
TypeScript
842 lines
33 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';
|
||
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 0–100. */
|
||
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,
|
||
},
|
||
});
|
||
});
|
||
}
|