sync
This commit is contained in:
+219
-16
@@ -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 3–0 / 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 3–0 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user