economy and rematch

This commit is contained in:
React User
2026-05-04 12:54:30 +00:00
parent b104d35a9d
commit a09b33e158
8 changed files with 181 additions and 16 deletions
+5 -1
View File
@@ -8,6 +8,7 @@ import {
insertMatchForNewRoom, insertMatchForNewRoom,
insertRematchMatch, insertRematchMatch,
loadUsernamesForUserPair, loadUsernamesForUserPair,
computeRcPrizeFromEntryFee,
MATCH_ENTRY_FEE_RC, MATCH_ENTRY_FEE_RC,
registerMatchRoutes, registerMatchRoutes,
updateMatchUserBlue, updateMatchUserBlue,
@@ -98,17 +99,20 @@ function roomResponseForViewer(room: Room, viewerUserId: number): Room {
const idx = room.Players.findIndex((p) => p.UserId === viewerUserId); const idx = room.Players.findIndex((p) => p.UserId === viewerUserId);
const your_team: 'red' | 'blue' | null = idx === 0 ? 'red' : idx === 1 ? 'blue' : null; const your_team: 'red' | 'blue' | null = idx === 0 ? 'red' : idx === 1 ? 'blue' : null;
const entry_fee = room.entry_fee ?? MATCH_ENTRY_FEE_RC; const entry_fee = room.entry_fee ?? MATCH_ENTRY_FEE_RC;
return { ...room, entry_fee, your_team }; const rc_prize = computeRcPrizeFromEntryFee(entry_fee);
return { ...room, entry_fee, rc_prize, your_team };
} }
/** Same envelope as `POST /internal/rematch` success: both sides when seated, else `null` for open slot. */ /** Same envelope as `POST /internal/rematch` success: both sides when seated, else `null` for open slot. */
function roomMatchSuccessEnvelope(room: Room): MatchRoomSuccessEnvelope { function roomMatchSuccessEnvelope(room: Room): MatchRoomSuccessEnvelope {
const entry_fee = room.entry_fee ?? MATCH_ENTRY_FEE_RC; const entry_fee = room.entry_fee ?? MATCH_ENTRY_FEE_RC;
const rc_prize = computeRcPrizeFromEntryFee(entry_fee);
const redId = room.Players[0]?.UserId; const redId = room.Players[0]?.UserId;
const blueId = room.Players[1]?.UserId; const blueId = room.Players[1]?.UserId;
return { return {
ok: true, ok: true,
entry_fee, entry_fee,
rc_prize,
for_red: redId != null ? roomResponseForViewer(room, redId) : null, for_red: redId != null ? roomResponseForViewer(room, redId) : null,
for_blue: blueId != null ? roomResponseForViewer(room, blueId) : null, for_blue: blueId != null ? roomResponseForViewer(room, blueId) : null,
}; };
+2 -2
View File
@@ -1,3 +1,3 @@
Logger initiated at 05/02/2026 22:12:20 Logger initiated at 05/04/2026 12:01:54
[05/02/2026 22:12:20] Configured dedicated match reporting for match id 244 with secret 38516e39d3406225b7c3015a66e5d25c54e9b2024c0755212122bdcea3e3a328 and internal api base https://kickkingsapi.playpoolstudios.com [05/04/2026 12:01:54] Configured dedicated match reporting for match id 248 with secret 38516e39d3406225b7c3015a66e5d25c54e9b2024c0755212122bdcea3e3a328 and internal api base https://kickkingsapi.playpoolstudios.com
+20
View File
@@ -0,0 +1,20 @@
[05/04/2026 09:10:06] Starting server at port 26950
[05/04/2026 09:10:06] Failed to fetch red and blue names, isServer?
[05/04/2026 09:10:06] Starting auto close watchdog
[05/04/2026 09:10:06] Active player connections: 0
[05/04/2026 09:10:10] Active player connections: 1
[05/04/2026 09:10:11] Client 15 set team to Red
[05/04/2026 09:10:12] Dedicated match PATCH success: 200 {"ok":true,"id":245}
[05/04/2026 09:10:15] Active player connections: 2
[05/04/2026 09:10:15] Game started On Server
[05/04/2026 09:10:15] Client 16 set team to Red
[05/04/2026 09:10:19] Active player connections: 1
[05/04/2026 09:10:20] Active player connections: 0
[05/04/2026 09:10:25] Server will be closed due to no players in, 60
[05/04/2026 09:10:35] Server will be closed due to no players in, 50
[05/04/2026 09:10:45] Server will be closed due to no players in, 40
[05/04/2026 09:10:55] Server will be closed due to no players in, 30
[05/04/2026 09:11:05] Server will be closed due to no players in, 20
[05/04/2026 09:11:15] Server will be closed due to no players in, 10
[05/04/2026 09:11:25] All players left, exiting
[05/04/2026 09:11:26] Dedicated match PATCH success: 200 {"ok":true,"id":245}
+21
View File
@@ -0,0 +1,21 @@
[05/04/2026 09:58:29] Starting server at port 26684
[05/04/2026 09:58:29] Failed to fetch red and blue names, isServer?
[05/04/2026 09:58:29] Starting auto close watchdog
[05/04/2026 09:58:29] Active player connections: 0
[05/04/2026 09:58:34] Server will be closed due to no players in, 60
[05/04/2026 09:58:34] Active player connections: 1
[05/04/2026 09:58:34] Client 15 set team to Red
[05/04/2026 09:58:35] Active player connections: 2
[05/04/2026 09:58:35] Game started On Server
[05/04/2026 09:58:35] Dedicated match PATCH success: 200 {"ok":true,"id":246}
[05/04/2026 09:58:35] Client 16 set team to Red
[05/04/2026 10:12:02] Active player connections: 1
[05/04/2026 10:14:21] Active player connections: 0
[05/04/2026 10:14:26] Server will be closed due to no players in, 60
[05/04/2026 10:14:36] Server will be closed due to no players in, 50
[05/04/2026 10:14:46] Server will be closed due to no players in, 40
[05/04/2026 10:14:56] Server will be closed due to no players in, 30
[05/04/2026 10:15:06] Server will be closed due to no players in, 20
[05/04/2026 10:15:16] Server will be closed due to no players in, 10
[05/04/2026 10:15:26] All players left, exiting
[05/04/2026 10:15:28] Dedicated match PATCH success: 200 {"ok":true,"id":246}
+22
View File
@@ -0,0 +1,22 @@
[05/04/2026 11:54:28] Starting server at port 26052
[05/04/2026 11:54:29] Failed to fetch red and blue names, isServer?
[05/04/2026 11:54:29] Starting auto close watchdog
[05/04/2026 11:54:29] Active player connections: 0
[05/04/2026 11:54:33] Active player connections: 1
[05/04/2026 11:54:34] Client 15 set team to Red
[05/04/2026 11:54:34] Active player connections: 2
[05/04/2026 11:54:34] Game started On Server
[05/04/2026 11:54:34] Client 16 set team to Red
[05/04/2026 11:54:35] Dedicated match PATCH success: 200 {"ok":true,"id":247}
[05/04/2026 11:54:37] force = 70 * 0.9955804 = 69.69063
[05/04/2026 11:54:37] launching puck at (4.94, -0.80) with (-344.01, 55.48) force
[05/04/2026 11:55:39] Active player connections: 1
[05/04/2026 11:55:40] Active player connections: 0
[05/04/2026 11:55:45] Server will be closed due to no players in, 60
[05/04/2026 11:55:55] Server will be closed due to no players in, 50
[05/04/2026 11:56:05] Server will be closed due to no players in, 40
[05/04/2026 11:56:15] Server will be closed due to no players in, 30
[05/04/2026 11:56:25] Server will be closed due to no players in, 20
[05/04/2026 11:56:35] Server will be closed due to no players in, 10
[05/04/2026 11:56:45] All players left, exiting
[05/04/2026 11:56:46] Dedicated match PATCH success: 200 {"ok":true,"id":247}
+63
View File
@@ -0,0 +1,63 @@
[05/04/2026 12:01:54] Starting server at port 26825
[05/04/2026 12:01:54] Failed to fetch red and blue names, isServer?
[05/04/2026 12:01:54] Starting auto close watchdog
[05/04/2026 12:01:55] Active player connections: 0
[05/04/2026 12:02:00] Server will be closed due to no players in, 60
[05/04/2026 12:02:00] Active player connections: 1
[05/04/2026 12:02:00] Client 15 set team to Blue
[05/04/2026 12:02:00] Active player connections: 2
[05/04/2026 12:02:00] Game started On Server
[05/04/2026 12:02:01] Client 16 set team to Red
[05/04/2026 12:02:01] Dedicated match PATCH success: 200 {"ok":true,"id":248}
[05/04/2026 12:02:02] Dedicated match PATCH success: 200 {"ok":true,"id":248}
[05/04/2026 12:02:05] force = 70 * 0.9955804 = 69.69063
[05/04/2026 12:02:05] launching puck at (4.96, -0.65) with (-345.53, 45.01) force
[05/04/2026 12:02:11] force = 70 * 0.9955804 = 69.69063
[05/04/2026 12:02:11] launching puck at (-4.60, 1.96) with (320.69, -136.31) force
[05/04/2026 12:02:17] force = 70 * 0.9955804 = 69.69063
[05/04/2026 12:02:17] launching puck at (-1.29, -4.83) with (90.25, 336.56) force
[05/04/2026 12:02:23] force = 70 * 0.9955804 = 69.69063
[05/04/2026 12:02:23] launching puck at (-4.97, 0.55) with (346.37, -38.07) force
[05/04/2026 12:02:27] force = 70 * 0.9955804 = 69.69063
[05/04/2026 12:02:27] launching puck at (4.62, -1.91) with (-321.95, 133.30) force
[05/04/2026 12:02:28] Red goal scored, red score is now 1
[05/04/2026 12:02:36] force = 70 * 0.9955804 = 69.69063
[05/04/2026 12:02:36] launching puck at (0.05, 5.00) with (-3.29, -348.44) force
[05/04/2026 12:02:42] force = 70 * 0.9955804 = 69.69063
[05/04/2026 12:02:42] launching puck at (2.22, -4.48) with (-154.84, 312.16) force
[05/04/2026 12:02:49] force = 70 * 0.9955804 = 69.69063
[05/04/2026 12:02:49] launching puck at (0.79, -4.94) with (-55.40, 344.02) force
[05/04/2026 12:02:54] force = 70 * 0.9955804 = 69.69063
[05/04/2026 12:02:54] launching puck at (-1.28, -4.83) with (89.05, 336.88) force
[05/04/2026 12:03:05] force = 70 * 0.9955804 = 69.69063
[05/04/2026 12:03:05] launching puck at (3.37, -3.70) with (-234.68, 257.57) force
[05/04/2026 12:03:09] force = 70 * 0.9955804 = 69.69063
[05/04/2026 12:03:09] launching puck at (-0.72, 4.95) with (50.50, -344.77) force
[05/04/2026 12:03:16] force = 70 * 0.9955804 = 69.69063
[05/04/2026 12:03:16] launching puck at (-0.86, 4.93) with (59.61, -343.32) force
[05/04/2026 12:03:17] Blue goal scored, blue score is now 1
[05/04/2026 12:03:27] force = 70 * 0.3396594 = 23.77616
[05/04/2026 12:03:27] launching puck at (0.00, -1.09) with (-0.06, 25.89) force
[05/04/2026 12:03:32] force = 70 * 0.9955804 = 69.69063
[05/04/2026 12:03:32] launching puck at (-3.18, 3.86) with (221.33, -269.14) force
[05/04/2026 12:03:39] force = 70 * 0.9955804 = 69.69063
[05/04/2026 12:03:39] launching puck at (0.13, -5.00) with (-8.80, 348.34) force
[05/04/2026 12:03:39] Red goal scored, red score is now 2
[05/04/2026 12:03:48] force = 70 * 0.9955804 = 69.69063
[05/04/2026 12:03:48] launching puck at (-3.23, 3.82) with (225.10, -265.98) force
[05/04/2026 12:03:56] force = 70 * 0.898546 = 62.89822
[05/04/2026 12:03:56] launching puck at (0.10, -3.97) with (-6.07, 250.01) force
[05/04/2026 12:03:57] Red goal scored, red score is now 3
[05/04/2026 12:03:58] Dedicated match PATCH success: 200 {"ok":true,"id":248}
[05/04/2026 12:03:59] Dedicated match PATCH success: 200 {"ok":true,"id":248,"winner_id":5,"economy":{"entry_fee_rc":21,"winner_rc_bonus":34,"participant_cc":100}}
[05/04/2026 12:04:08] Rematch requested
[05/04/2026 12:04:39] Active player connections: 1
[05/04/2026 12:04:43] Active player connections: 0
[05/04/2026 12:04:48] Server will be closed due to no players in, 60
[05/04/2026 12:04:58] Server will be closed due to no players in, 50
[05/04/2026 12:05:08] Server will be closed due to no players in, 40
[05/04/2026 12:05:18] Server will be closed due to no players in, 30
[05/04/2026 12:05:28] Server will be closed due to no players in, 20
[05/04/2026 12:05:38] Server will be closed due to no players in, 10
[05/04/2026 12:05:48] All players left, exiting
[05/04/2026 12:05:48] Dedicated match PATCH success: 200 {"ok":true,"id":248}
+46 -13
View File
@@ -6,9 +6,14 @@ import { createSupabase, supabaseConfigured } from './auth_bridge';
/** Per player on the match: deducted when a winner is recorded. */ /** Per player on the match: deducted when a winner is recorded. */
export const MATCH_ENTRY_FEE_RC = 21; export const MATCH_ENTRY_FEE_RC = 21;
/** Added to the winning player's RC (after entry fee). */
const MATCH_WINNER_RC_BONUS = 34; /** `prize_pool = entry_fee * 2`, `server_fee = ceil(prize_pool / 10)`, returns `prize_pool - server_fee`. */
/** Added to each player's CC when a winner is recorded. */ export function computeRcPrizeFromEntryFee(entryFee: number): number {
const prizePool = entryFee * 2;
const serverFee = Math.ceil(prizePool / 10);
return prizePool - serverFee;
}
/** Added to each player's CC when a winner is recorded (default if `matches.prize_cc` is null). */
const MATCH_PARTICIPANT_CC_REWARD = 100; const MATCH_PARTICIPANT_CC_REWARD = 100;
function coerceId(id: unknown): number | null { function coerceId(id: unknown): number | null {
@@ -88,6 +93,7 @@ export async function loadUsernamesForUserPair(
export type MatchRoomSuccessEnvelope = { export type MatchRoomSuccessEnvelope = {
ok: true; ok: true;
entry_fee: number; entry_fee: number;
rc_prize: number;
for_red: Room | null; for_red: Room | null;
for_blue: Room | null; for_blue: Room | null;
}; };
@@ -132,18 +138,40 @@ export async function getPlayerLast10MatchRecord(
return { l10_wins, l10_losses }; return { l10_wins, l10_losses };
} }
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;
}
/** /**
* Entry fee from each participant, winner bonus RC, CC for both. Idempotent callers should set winner only once. * Each player pays `entry_fee` RC; winner receives `computeRcPrizeFromEntryFee(entry_fee)` RC; each gets `prizeCc` CC.
* Idempotent callers should set winner only once.
*/ */
async function applyMatchWinnerEconomy( async function applyMatchWinnerEconomy(
supabase: SupabaseClient, supabase: SupabaseClient,
userRed: number | null, userRed: number | null,
userBlue: number | null, userBlue: number | null,
winnerId: number winnerId: number,
entryFee: number,
prizeCc: number
): Promise<string | null> { ): Promise<string | null> {
const ids = [...new Set([userRed, userBlue].filter((x): x is number => x != null && x >= 1))]; const ids = [...new Set([userRed, userBlue].filter((x): x is number => x != null && x >= 1))];
if (ids.length === 0) return null; if (ids.length === 0) return null;
const rcPrize = computeRcPrizeFromEntryFee(entryFee);
const { data: rows, error: selErr } = await supabase.from('users').select('id, rc, cc').in('id', ids); const { data: rows, error: selErr } = await supabase.from('users').select('id, rc, cc').in('id', ids);
if (selErr) return selErr.message; if (selErr) return selErr.message;
if (!rows || rows.length !== ids.length) return 'One or more players not found in users'; if (!rows || rows.length !== ids.length) return 'One or more players not found in users';
@@ -153,9 +181,9 @@ async function applyMatchWinnerEconomy(
if (uid == null) continue; if (uid == null) continue;
const baseRc = Number((row as { rc: unknown }).rc ?? 0); const baseRc = Number((row as { rc: unknown }).rc ?? 0);
const baseCc = Number((row as { cc: unknown }).cc ?? 0); const baseCc = Number((row as { cc: unknown }).cc ?? 0);
let newRc = baseRc - MATCH_ENTRY_FEE_RC; let newRc = baseRc - entryFee;
if (uid === winnerId) newRc += MATCH_WINNER_RC_BONUS; if (uid === winnerId) newRc += rcPrize;
const newCc = baseCc + MATCH_PARTICIPANT_CC_REWARD; const newCc = baseCc + prizeCc;
const { error: upErr } = await supabase.from('users').update({ rc: newRc, cc: newCc }).eq('id', uid); const { error: upErr } = await supabase.from('users').update({ rc: newRc, cc: newCc }).eq('id', uid);
if (upErr) return upErr.message; if (upErr) return upErr.message;
} }
@@ -386,7 +414,7 @@ export function registerMatchRoutes(
const supabase = createSupabase(settings); const supabase = createSupabase(settings);
const { data: row, error: fetchErr } = await supabase const { data: row, error: fetchErr } = await supabase
.from('matches') .from('matches')
.select('user_red, user_blue') .select('user_red, user_blue, entry_fee, prize_cc')
.eq('id', matchId) .eq('id', matchId)
.maybeSingle(); .maybeSingle();
@@ -401,6 +429,10 @@ export function registerMatchRoutes(
const userRed = coerceId((row as { user_red: unknown }).user_red); const userRed = coerceId((row as { user_red: unknown }).user_red);
const userBlue = coerceId((row as { user_blue: unknown }).user_blue); 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; const winnerId = side === 'red' ? userRed : userBlue;
if (winnerId == null) { if (winnerId == null) {
res.status(400).json({ ok: false, error: `${side} slot has no user on this match` }); res.status(400).json({ ok: false, error: `${side} slot has no user on this match` });
@@ -429,7 +461,7 @@ export function registerMatchRoutes(
return; return;
} }
const payErr = await applyMatchWinnerEconomy(supabase, userRed, userBlue, winnerId); const payErr = await applyMatchWinnerEconomy(supabase, userRed, userBlue, winnerId, entryFee, prizeCc);
if (payErr != null) { if (payErr != null) {
console.error('matches: economy after winner failed', matchId, payErr); console.error('matches: economy after winner failed', matchId, payErr);
await supabase.from('matches').update({ winner_id: null }).eq('id', matchId).eq('winner_id', winnerId); await supabase.from('matches').update({ winner_id: null }).eq('id', matchId).eq('winner_id', winnerId);
@@ -437,14 +469,15 @@ export function registerMatchRoutes(
return; return;
} }
const rcPrize = computeRcPrizeFromEntryFee(entryFee);
res.json({ res.json({
ok: true, ok: true,
id: matchId, id: matchId,
winner_id: winnerId, winner_id: winnerId,
economy: { economy: {
entry_fee_rc: MATCH_ENTRY_FEE_RC, entry_fee_rc: entryFee,
winner_rc_bonus: MATCH_WINNER_RC_BONUS, rc_prize: rcPrize,
participant_cc: MATCH_PARTICIPANT_CC_REWARD, participant_cc: prizeCc,
}, },
}); });
}); });
+2
View File
@@ -49,6 +49,8 @@ export interface Room {
match_id?: number; match_id?: number;
/** RC entry fee for this match (matches `public.matches.entry_fee`). */ /** RC entry fee for this match (matches `public.matches.entry_fee`). */
entry_fee?: number; entry_fee?: number;
/** Net RC prize pool for the winner after server fee (derived from `entry_fee`). */
rc_prize?: number;
/** Only on GET / responses: authenticated users team (Players[0]=red, Players[1]=blue) */ /** Only on GET / responses: authenticated users team (Players[0]=red, Players[1]=blue) */
your_team?: 'red' | 'blue' | null; your_team?: 'red' | 'blue' | null;
} }