From 0fa00e67598c1f8997e36ca4cc6370a2c74c8f97 Mon Sep 17 00:00:00 2001 From: jd Date: Sun, 22 Feb 2026 02:07:50 +0000 Subject: [PATCH] Fixed some behaviour in Elo calculation. Began implement match logic. --- src/endpoints/matches.ts | 43 +++++++ src/index.ts | 9 +- src/orm/matches.ts | 179 ++++++++++++++++++++++++++++++ src/orm/orm.ts | 2 + src/routes/matches.ts | 11 ++ src/utilities/claimDefinitions.ts | 16 +-- src/utilities/elo.ts | 32 +++--- src/utilities/requestModels.ts | 6 +- src/utilities/responseHelper.ts | 10 +- src/utilities/secureIds.ts | 12 ++ 10 files changed, 284 insertions(+), 36 deletions(-) create mode 100644 src/endpoints/matches.ts create mode 100644 src/orm/matches.ts create mode 100644 src/routes/matches.ts diff --git a/src/endpoints/matches.ts b/src/endpoints/matches.ts new file mode 100644 index 0000000..f679762 --- /dev/null +++ b/src/endpoints/matches.ts @@ -0,0 +1,43 @@ +import { orm } from '../orm/orm'; +import { UnwrappedRequest } from '../utilities/guard'; +import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper'; +import { CreateMatchRequest } from '../utilities/requestModels'; +import { GameId, MatchId, PlayerId, UserId } from '../utilities/secureIds'; +import { MatchParticipant } from '../orm/matches'; + +async function create(request: UnwrappedRequest): Promise { + try { + const newUser = await orm.matches.create({ + gameId: GameId.fromHash(request.body.gameId), + ownerId: request.claims.userId as UserId, + participants: request.body.participants.map( + (x) => new MatchParticipant(PlayerId.fromHash(x.playerId), x.standing), + ), + }); + return new CreatedResponse(newUser); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function get(request: UnwrappedRequest): Promise { + try { + return new OkResponse(await orm.matches.get(MatchId.fromHash(request.params.id), request.claims)); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function drop(request: UnwrappedRequest): Promise { + try { + return new OkResponse(await orm.matches.drop(UserId.fromHash(request.params.id), request.claims)); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +export default { + create, + get, + drop, +}; diff --git a/src/index.ts b/src/index.ts index b9d5277..a6e9e06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,20 +6,23 @@ import games from './routes/games'; import invites from './routes/invites'; import collections from './routes/collections'; import { buildRoute } from './utilities/routeBuilder'; +import { MatchId } from './utilities/secureIds'; +import matches from './routes/matches'; const server = Bun.serve({ routes: buildRoute({ - [process.env.API_ROOT_PATH ?? '']:{ + [process.env.API_ROOT_PATH ?? '']: { auth, users, players, games, invites, collections, + matches, }, - 'test': { + test: { GET: () => { - return new OkResponse(); + return new OkResponse(MatchId.fromID('2').value); }, }, }) as any, diff --git a/src/orm/matches.ts b/src/orm/matches.ts new file mode 100644 index 0000000..3ce33b5 --- /dev/null +++ b/src/orm/matches.ts @@ -0,0 +1,179 @@ +import { Claims } from './claims'; +import { sql } from 'bun'; +import { first, orderBy } from 'lodash'; +import { BadRequestError, NotFoundError, UnauthorizedError } from '../utilities/errors'; +import { GameId, MatchId, PlayerId, UserId } from '../utilities/secureIds'; +import { calculateElos } from '../utilities/elo'; + +export class MatchParticipant { + matchId?: MatchId; + playerId: PlayerId; + gamesPlayed?: number; + standing: number; + elo: number; + eloChange: number; + isRatingLocked?: boolean; + + constructor( + playerId: PlayerId, + standing: number, + eloChange: number = 0, + gamesPlayed?: number, + elo: number = 1000, + matchId?: MatchId, + ) { + this.matchId = matchId; + this.playerId = playerId; + this.standing = standing; + this.elo = elo; + this.gamesPlayed = gamesPlayed; + this.eloChange = eloChange; + } +} + +export class Match { + id: MatchId; + gameId: GameId; + players: MatchParticipant[]; + owner: UserId; + + constructor(id: MatchId, gameId: GameId, players: MatchParticipant[], owner: UserId) { + this.id = id; + this.gameId = gameId; + this.players = players; + this.owner = owner; + } +} + +export class MatchOrm { + async create({ + gameId, + participants, + ownerId, + }: { + gameId: GameId; + participants: MatchParticipant[]; + ownerId: UserId; + }): Promise { + await sql`INSERT INTO matches (game_id, owning_user_id) VALUES (${gameId.raw}, ${ownerId.raw})`; + const newMatchId = MatchId.fromID((first(await sql`SELECT lastval();`) as any)?.lastval as string); + + const players = await sql` + SELECT p.id, + p.is_rating_locked, + (CASE WHEN p.is_rating_locked THEN 1000 ELSE 1000 + COALESCE(sum(mp.elo_change), 0) END) as elo, + (CASE WHEN p.is_rating_locked THEN 0 ELSE count(mp.*) END) as games_played + FROM players p + LEFT JOIN match_players mp ON mp.player_id = p.id + WHERE p.id IN ${sql(participants.map((x) => x.playerId.raw))} + GROUP BY p.id;`; + + for (let i in participants) { + const player = players.find((x: any) => x.id === participants[i].playerId.raw); + participants[i].elo = parseInt(player.elo); + participants[i].gamesPlayed = parseInt(player.games_played); + participants[i].isRatingLocked = player.is_rating_locked; + } + + const amendedPlayers = calculateElos(participants); + + await sql.transaction(async (tx) => { + for (let i in amendedPlayers) { + await tx` + INSERT INTO match_players(match_id, player_id, standing, elo_change) + VALUES (${newMatchId.raw}, + ${amendedPlayers[i].playerId.raw}, + ${amendedPlayers[i].standing}, + ${amendedPlayers[i].isRatingLocked ? 0 : amendedPlayers[i].eloChange})`; + + if (amendedPlayers[i].isRatingLocked) { + continue; + } + + await tx`UPDATE players SET elo=${amendedPlayers[i].elo} WHERE id=${amendedPlayers[i].playerId.raw}`; + } + }); + + return await this.get(newMatchId); + } + + async get(id: MatchId, claims?: Claims): Promise { + const dbResult = await sql` + SELECT m.id as match_id, + m.owning_user_id as owner_id, + g.id as game_id, + g.name as game_name, + p.id as player_id, + p.name as player_name, + p.elo as elo, + mp.standing as standing, + mp.elo_change as elo_change + FROM matches m + LEFT JOIN games g ON g.id = m.game_id + LEFT JOIN match_players mp ON mp.match_id = m.id + LEFT JOIN players p ON p.id = mp.player_id + WHERE m.id = ${id.raw}`; + + if ( + !( + Claims.test(Claims.ADMIN, claims) || + (Claims.test(Claims.MATCHES.OWNED.READ, claims) && dbResult?.[0]?.owner_id === claims?.userId?.raw) || + (Claims.test(Claims.MATCHES.PARTICIPANT.READ, claims) && + dbResult?.some((x: any) => x.player_id === claims?.userId?.raw)) + ) + ) { + throw new UnauthorizedError(); + } + + if (!dbResult) { + throw new NotFoundError('No matching user exists'); + } + + return new Match( + MatchId.fromID(dbResult?.[0]?.match_id), + GameId.fromID(dbResult?.[0]?.game_id), + orderBy( + dbResult + .filter((x: any) => x.player_id) + .map( + (x: any) => + new MatchParticipant( + PlayerId.fromID(x.player_id), + parseInt(x.standing), + parseInt(x.elo_change), + undefined, + parseInt(x.elo), + ), + ), + 'standing', + 'asc', + ), + UserId.fromID(dbResult?.[0]?.owner_id), + ); + } + + async drop(id: UserId, claims?: Claims): Promise { + // if ( + // !( + // Claims.test(Claims.ADMIN, claims) || + // Claims.test(Claims.USERS.OTHER.DELETE, claims) || + // (Claims.test(Claims.USERS.SELF.DELETE, claims) && id === claims?.userId) + // ) + // ) { + // throw new UnauthorizedError(); + // } + // + // // Ensure user exists before attempting to delete + // await this.get(id); + // await sql.transaction(async (tx) => { + // await tx`DELETE + // FROM user_claims + // WHERE user_id = ${id.raw}`; + // await tx`DELETE + // FROM users + // WHERE id = ${id.raw}`; + // }); + + return; + } +} diff --git a/src/orm/orm.ts b/src/orm/orm.ts index 1f154c7..ef55a67 100644 --- a/src/orm/orm.ts +++ b/src/orm/orm.ts @@ -4,6 +4,7 @@ import { PlayersOrm } from './players'; import { GamesOrm } from './games'; import { InvitesOrm } from './invites'; import { CollectionsOrm } from './collections'; +import { MatchOrm } from './matches'; class Orm { readonly claims: ClaimsOrm = new ClaimsOrm(); @@ -12,6 +13,7 @@ class Orm { readonly games: GamesOrm = new GamesOrm(); readonly invites: InvitesOrm = new InvitesOrm(); readonly collections: CollectionsOrm = new CollectionsOrm(); + readonly matches: MatchOrm = new MatchOrm(); } export const orm = new Orm(); diff --git a/src/routes/matches.ts b/src/routes/matches.ts new file mode 100644 index 0000000..01517c5 --- /dev/null +++ b/src/routes/matches.ts @@ -0,0 +1,11 @@ +import { guard } from '../utilities/guard'; +import matches from '../endpoints/matches'; +import { Claims } from '../orm/claims'; + +export default { + 'POST': guard(matches.create, [Claims.ADMIN, Claims.MATCHES.CREATE]), + ':id': { + GET: guard(matches.get, [Claims.ADMIN, Claims.MATCHES.OWNED.READ, Claims.MATCHES.PARTICIPANT.READ]), + DELETE: guard(matches.drop, [Claims.ADMIN, Claims.MATCHES.OWNED.DELETE, Claims.USERS.SELF.UPDATE]), + }, +}; diff --git a/src/utilities/claimDefinitions.ts b/src/utilities/claimDefinitions.ts index 1f0ce0f..e97c34c 100644 --- a/src/utilities/claimDefinitions.ts +++ b/src/utilities/claimDefinitions.ts @@ -82,24 +82,24 @@ export class ClaimDefinition { }; public static readonly MATCHES = { CREATE: 'MATCHES_CREATE', + COMMENTS: { + ADD: 'MATCHES_UNOWNED_COMMENTS_ADD', + }, OWNED: { READ: 'MATCHES_OWNED_READ', - UPDATE: 'MATCHES_OWNED_UPDATE', DELETE: 'MATCHES_OWNED_DELETE', COMMENTS: { ADD: 'MATCHES_OWNED_COMMENTS_ADD', DELETE: 'MATCHES_OWNED_COMMENTS_DELETE', }, }, - UNOWNED: { - READ: 'MATCHES_UNOWNED_READ', - UPDATE: 'MATCHES_UNOWNED_UPDATE', - DELETE: 'MATCHES_UNOWNED_DELETE', + PARTICIPANT: { + READ: 'MATCHES_PARTICIPANT_READ', + LEAVE: 'MATCHES_LEAVE', COMMENTS: { - ADD: 'MATCHES_UNOWNED_COMMENTS_ADD', - DELETE: 'MATCHES_UNOWNED_COMMENTS_DELETE', + ADD: 'MATCHES_PARTICIPANT_COMMENTS_ADD', }, - }, + } }; public static readonly COLLECTIONS = { CREATE: 'COLLECTIONS_CREATE', diff --git a/src/utilities/elo.ts b/src/utilities/elo.ts index 090aaa4..60208ea 100644 --- a/src/utilities/elo.ts +++ b/src/utilities/elo.ts @@ -1,32 +1,26 @@ import { orderBy } from 'lodash'; +import { MatchParticipant } from '../orm/matches'; -interface GamePlayer { - id: string; - elo: number; - gamesPlayed: number; - standing: number; - eloChange?: number; -} -export interface GamePlayed { - players: GamePlayer[]; -} - -export function calculateElos(game: GamePlayed, provisionalPeriod: number = 1): GamePlayed { - const orderedResults = orderBy(game.players, 'standing', 'asc'); +export function calculateElos(players: MatchParticipant[], provisionalPeriod: number = 1): MatchParticipant[] { + const orderedResults = orderBy(players, (x:any) => parseInt(x.standing ?? 0), 'asc'); for (let i = 0; i < orderedResults.length - 1; i++) { for (let j = i + 1; j < orderedResults.length; j++) { - const challengerResults = calculateEloChange(orderedResults[i].elo, orderedResults[j].elo); + const challengerResults = calculateEloChange( + orderedResults[i].elo, + orderedResults[j].elo, + orderedResults[i].standing === orderedResults[j].standing, + ); orderedResults[i].eloChange = (orderedResults[i].eloChange ?? 0) + - challengerResults.winnerChange * Math.min(1, orderedResults[j].gamesPlayed / provisionalPeriod); + challengerResults.winnerChange * + Math.min(1, ((orderedResults[j].gamesPlayed as number) + 1) / provisionalPeriod); orderedResults[j].eloChange = (orderedResults[j].eloChange ?? 0) + - challengerResults.loserChange * Math.min(1, orderedResults[i].gamesPlayed / provisionalPeriod); + challengerResults.loserChange * + Math.min(1, ((orderedResults[i].gamesPlayed as number) + 1) / provisionalPeriod); } } - return { - players: orderedResults, - }; + return orderedResults; } interface EloResult { diff --git a/src/utilities/requestModels.ts b/src/utilities/requestModels.ts index 330e64c..3d26d35 100644 --- a/src/utilities/requestModels.ts +++ b/src/utilities/requestModels.ts @@ -49,4 +49,8 @@ export interface UpdateCollectionRequest { } export interface GameToCollectionRequest { gameId: string; -} \ No newline at end of file +} +export interface CreateMatchRequest { + gameId: string; + participants: { playerId: string; standing: number }[]; +} diff --git a/src/utilities/responseHelper.ts b/src/utilities/responseHelper.ts index 5d7fa27..52f14b7 100644 --- a/src/utilities/responseHelper.ts +++ b/src/utilities/responseHelper.ts @@ -1,5 +1,5 @@ import { BadRequestError, NotFoundError, UnauthorizedError } from './errors'; -import { clamp, isArray } from 'lodash'; +import { clamp, isObject } from 'lodash'; import { UnwrappedRequest } from './guard'; export class ErrorResponse extends Response { @@ -52,11 +52,11 @@ export class OkResponse extends Response { constructor(body?: any) { if (body) { return Response.json( - isArray(body) - ? body - : { + isObject(body) + ? { ...body, - }, + } + : body, { status: 200, headers: { diff --git a/src/utilities/secureIds.ts b/src/utilities/secureIds.ts index 5ba74e5..1894e9c 100644 --- a/src/utilities/secureIds.ts +++ b/src/utilities/secureIds.ts @@ -122,3 +122,15 @@ export class CollectionId extends SecureId { return super.fromID(id, CollectionId); } } + +export class MatchId extends SecureId { + protected static override hashPrefix: string = 'MatchId'; + + public static fromHash(hash: string): MatchId { + return super.fromHash(hash, MatchId); + } + + public static fromID(id: string): MatchId { + return super.fromID(id, MatchId); + } +}