rematch
This commit is contained in:
+143
-4
@@ -1,11 +1,11 @@
|
||||
import express, { Express, Request, Response } from 'express';
|
||||
import * as crypto from 'crypto';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { Settings } from './types';
|
||||
import { Room, Settings } from './types';
|
||||
import { createSupabase, supabaseConfigured } from './auth_bridge';
|
||||
|
||||
/** Per player on the match: deducted when a winner is recorded. */
|
||||
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;
|
||||
/** Added to each player's CC when a winner is recorded. */
|
||||
@@ -26,7 +26,8 @@ function coerceId(id: unknown): number | null {
|
||||
export async function insertMatchForNewRoom(settings: Settings, userRedId?: number): Promise<number | null> {
|
||||
if (!supabaseConfigured(settings)) return null;
|
||||
const supabase = createSupabase(settings);
|
||||
const payload = userRedId == null ? {} : { user_red: userRedId };
|
||||
const base = { entry_fee: MATCH_ENTRY_FEE_RC };
|
||||
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);
|
||||
@@ -35,6 +36,70 @@ export async function insertMatchForNewRoom(settings: Settings, userRedId?: numb
|
||||
return coerceId(data.id);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
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,
|
||||
@@ -141,7 +206,11 @@ function rejectUnlessDedicatedServer(req: Request, res: Response, settings: Sett
|
||||
return false;
|
||||
}
|
||||
|
||||
export function registerMatchRoutes(app: Express, settings: Settings): void {
|
||||
export function registerMatchRoutes(
|
||||
app: Express,
|
||||
settings: Settings,
|
||||
deps?: { createRematchRoom?: CreateRematchRoomFn }
|
||||
): void {
|
||||
const jsonParser = express.json();
|
||||
|
||||
/**
|
||||
@@ -167,6 +236,76 @@ export function registerMatchRoutes(app: Express, settings: Settings): void {
|
||||
});
|
||||
});
|
||||
|
||||
/** 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 });
|
||||
});
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user