threesome fixed

This commit is contained in:
API runner
2026-08-14 20:55:11 +00:00
parent b6ecf6ad7c
commit b933f09187
260 changed files with 17747 additions and 270 deletions
+347 -126
View File
@@ -3,13 +3,60 @@ 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';
const SYSTEM_ACCOUNT_ID = 1;
/** 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;
@@ -71,8 +118,8 @@ export type InsertMatchForNewRoomResult = {
/**
* Winner's net RC = both entries minus per-player fees.
* `fee_each = ceil(entry_fee * betFeePercent / 100)`, prize = `(entry_fee - fee_each) * 2` = `entry_fee * 2 - fee_each * 2`.
* Matches ledger rows: each player pays `fee_each` to system (`entry_fee`) and holds `entry_fee - fee_each`.
* `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);
@@ -209,6 +256,41 @@ export async function getPlayerLast10MatchRecord(
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') {
@@ -227,99 +309,77 @@ function coerceNonNegativeInt(v: unknown): number | null {
}
/**
* Each player pays `entry_fee` RC; winner receives net RC after `betFeePercent` taken from **each** entry; each gets `prizeCc` CC.
* Idempotent callers should set winner only once.
* Move both entry fees into the per-match escrow when both players have joined.
* Idempotent via `matches.entries_collected_at`.
*/
async function applyMatchWinnerEconomy(
async function callCollectMatchEntries(
supabase: SupabaseClient,
matchId: number,
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 lookupIds = [...new Set([...ids, SYSTEM_ACCOUNT_ID])];
const { data: rows, error: selErr } = await supabase.from('users').select('id, rc, cc').in('id', lookupIds);
if (selErr) return selErr.message;
if (!rows || rows.length !== lookupIds.length) return 'One or more users not found in users';
const byId = new Map<number, { rc: number; cc: number }>();
for (const row of rows) {
const uid = coerceId((row as { id: unknown }).id);
if (uid == null) continue;
byId.set(uid, {
rc: Number((row as { rc: unknown }).rc ?? 0),
cc: Number((row as { cc: unknown }).cc ?? 0),
});
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)) };
}
if (!byId.has(SYSTEM_ACCOUNT_ID)) return 'System account not found';
let totalEntryCollected = 0;
for (const uid of ids) {
const base = byId.get(uid);
if (!base) return `Player ${uid} not found in users`;
const baseRc = base.rc;
const baseCc = base.cc;
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;
totalEntryCollected += entryFee;
}
const system = byId.get(SYSTEM_ACCOUNT_ID)!;
const systemNewRc = system.rc + totalEntryCollected - rcPrize;
const { error: systemUpErr } = await supabase
.from('users')
.update({ rc: systemNewRc })
.eq('id', SYSTEM_ACCOUNT_ID);
if (systemUpErr) return systemUpErr.message;
const perPlayerBetFee = Math.ceil((entryFee * betFeePercent) / 100);
const perPlayerHold = entryFee - perPlayerBetFee;
const txRows: Array<{ from: number; to: number; amount: number; remarks: string; match_id: number }> = [];
for (const uid of ids) {
txRows.push({
from: uid,
to: SYSTEM_ACCOUNT_ID,
amount: perPlayerHold,
remarks: 'entry_hold',
match_id: matchId,
});
txRows.push({
from: uid,
to: SYSTEM_ACCOUNT_ID,
amount: perPlayerBetFee,
remarks: 'entry_fee',
match_id: matchId,
});
}
txRows.push({
from: SYSTEM_ACCOUNT_ID,
to: winnerId,
amount: rcPrize,
remarks: 'reward',
match_id: matchId,
});
const { error: txErr } = await supabase.from('transactions').insert(txRows);
if (txErr) return txErr.message;
return null;
const payload = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>;
return { ok: true, data: payload };
}
/** Second player joining the room = blue. */
/**
* 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 { error } = await supabase.from('matches').update({ user_blue: userBlueId }).eq('id', matchId);
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);
}
@@ -414,6 +474,47 @@ export function registerMatchRoutes(
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 }`.
@@ -506,7 +607,12 @@ export function registerMatchRoutes(
}
const supabase = createSupabase(settings);
const { data, error } = await supabase.from('matches').update(patch).eq('id', matchId).select('id').maybeSingle();
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 });
@@ -516,10 +622,67 @@ export function registerMatchRoutes(
res.status(404).json({ ok: false, error: 'Match not found' });
return;
}
res.json({ ok: true, id: matchId });
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_id` from match sides (`user_red` / `user_blue`). */
/** 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;
@@ -539,7 +702,7 @@ export function registerMatchRoutes(
const supabase = createSupabase(settings);
const { data: row, error: fetchErr } = await supabase
.from('matches')
.select('user_red, user_blue, entry_fee, prize_cc')
.select('user_red, user_blue, entry_fee, prize_cc, entries_collected_at, settled_at, winner_id')
.eq('id', matchId)
.maybeSingle();
@@ -564,56 +727,114 @@ export function registerMatchRoutes(
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' });
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;
}
const payErr = await applyMatchWinnerEconomy(
supabase,
matchId,
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}` });
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;
}
const rcPrize = computeRcPrizeFromEntryFee(entryFee, betFeePercent);
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: prizeCc,
participant_cc: participantCc,
},
});
});