This commit is contained in:
API runner
2026-09-04 19:11:49 +00:00
parent b933f09187
commit 3d063af9ae
130 changed files with 4876 additions and 53 deletions
+219 -16
View File
@@ -23,7 +23,11 @@ function mapEconomyRpcError(message: string): { status: number; error: string }
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')) {
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')) {
@@ -125,7 +129,7 @@ export function computeRcPrizeFromEntryFee(entryFee: number, betFeePercent: numb
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). */
/** 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 {
@@ -224,6 +228,12 @@ export type CreateRematchRoomFn = (args: {
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,
@@ -291,6 +301,11 @@ function daysSinceRegistration(createdAt: string): number {
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') {
@@ -308,6 +323,65 @@ function coerceNonNegativeInt(v: unknown): number | null {
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`.
@@ -327,15 +401,28 @@ async function callCollectMatchEntries(
/**
* 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
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)) };
@@ -422,10 +509,32 @@ function rejectUnlessDedicatedServer(req: Request, res: Response, settings: Sett
export function registerMatchRoutes(
app: Express,
settings: Settings,
deps?: { createRematchRoom?: CreateRematchRoomFn }
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.
@@ -665,6 +774,7 @@ export function registerMatchRoutes(
: `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,
@@ -675,6 +785,9 @@ export function registerMatchRoutes(
return;
}
if (entriesCollected) {
await markBothConnected(matchId);
}
res.json({
ok: true,
id: matchId,
@@ -699,10 +812,28 @@ export function registerMatchRoutes(
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')
.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();
@@ -728,29 +859,56 @@ export function registerMatchRoutes(
}
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) {
if (existingWinner === winnerId && (row as { settled_at: unknown }).settled_at != 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: side,
winner_id: winnerId,
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 ${winnerId} (${side})`);
recordHistory(
`Reward already settled for match ${matchId} winner ${actualWinnerId} (${actualSide}) mmr_delta=${mmrDelta ?? '—'}`
);
await dropRoomAfterSettle(matchId);
res.json({
ok: true,
id: matchId,
winner_id: winnerId,
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;
}
@@ -788,8 +946,22 @@ export function registerMatchRoutes(
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);
const settle = await callSettleMatchReward(
supabase,
matchId,
winnerId,
redConnected,
blueConnected,
redScore,
blueScore,
forfeitFlag
);
if (!settle.ok) {
logInfo('[settle-reward] failed', {
match_id: matchId,
@@ -809,33 +981,64 @@ export function registerMatchRoutes(
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: side,
winner_id: winnerId,
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 ${winnerId} (${side})`
: `Reward settle for match ${matchId}: winner ${winnerId} (${side}) prize ${rcPrize} house ${String(settle.data.house_fee ?? '—')} cc ${participantCc}`
? `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_id: winnerId,
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,
});
});
}