Fixed some behaviour in Elo calculation. Began implement match logic.
This commit is contained in:
43
src/endpoints/matches.ts
Normal file
43
src/endpoints/matches.ts
Normal file
@@ -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<CreateMatchRequest>): Promise<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -6,20 +6,23 @@ import games from './routes/games';
|
|||||||
import invites from './routes/invites';
|
import invites from './routes/invites';
|
||||||
import collections from './routes/collections';
|
import collections from './routes/collections';
|
||||||
import { buildRoute } from './utilities/routeBuilder';
|
import { buildRoute } from './utilities/routeBuilder';
|
||||||
|
import { MatchId } from './utilities/secureIds';
|
||||||
|
import matches from './routes/matches';
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
routes: buildRoute({
|
routes: buildRoute({
|
||||||
[process.env.API_ROOT_PATH ?? '']:{
|
[process.env.API_ROOT_PATH ?? '']: {
|
||||||
auth,
|
auth,
|
||||||
users,
|
users,
|
||||||
players,
|
players,
|
||||||
games,
|
games,
|
||||||
invites,
|
invites,
|
||||||
collections,
|
collections,
|
||||||
|
matches,
|
||||||
},
|
},
|
||||||
'test': {
|
test: {
|
||||||
GET: () => {
|
GET: () => {
|
||||||
return new OkResponse();
|
return new OkResponse(MatchId.fromID('2').value);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}) as any,
|
}) as any,
|
||||||
|
|||||||
179
src/orm/matches.ts
Normal file
179
src/orm/matches.ts
Normal file
@@ -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<Match> {
|
||||||
|
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<Match> {
|
||||||
|
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<void> {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { PlayersOrm } from './players';
|
|||||||
import { GamesOrm } from './games';
|
import { GamesOrm } from './games';
|
||||||
import { InvitesOrm } from './invites';
|
import { InvitesOrm } from './invites';
|
||||||
import { CollectionsOrm } from './collections';
|
import { CollectionsOrm } from './collections';
|
||||||
|
import { MatchOrm } from './matches';
|
||||||
|
|
||||||
class Orm {
|
class Orm {
|
||||||
readonly claims: ClaimsOrm = new ClaimsOrm();
|
readonly claims: ClaimsOrm = new ClaimsOrm();
|
||||||
@@ -12,6 +13,7 @@ class Orm {
|
|||||||
readonly games: GamesOrm = new GamesOrm();
|
readonly games: GamesOrm = new GamesOrm();
|
||||||
readonly invites: InvitesOrm = new InvitesOrm();
|
readonly invites: InvitesOrm = new InvitesOrm();
|
||||||
readonly collections: CollectionsOrm = new CollectionsOrm();
|
readonly collections: CollectionsOrm = new CollectionsOrm();
|
||||||
|
readonly matches: MatchOrm = new MatchOrm();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const orm = new Orm();
|
export const orm = new Orm();
|
||||||
|
|||||||
11
src/routes/matches.ts
Normal file
11
src/routes/matches.ts
Normal file
@@ -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]),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -82,24 +82,24 @@ export class ClaimDefinition {
|
|||||||
};
|
};
|
||||||
public static readonly MATCHES = {
|
public static readonly MATCHES = {
|
||||||
CREATE: 'MATCHES_CREATE',
|
CREATE: 'MATCHES_CREATE',
|
||||||
|
COMMENTS: {
|
||||||
|
ADD: 'MATCHES_UNOWNED_COMMENTS_ADD',
|
||||||
|
},
|
||||||
OWNED: {
|
OWNED: {
|
||||||
READ: 'MATCHES_OWNED_READ',
|
READ: 'MATCHES_OWNED_READ',
|
||||||
UPDATE: 'MATCHES_OWNED_UPDATE',
|
|
||||||
DELETE: 'MATCHES_OWNED_DELETE',
|
DELETE: 'MATCHES_OWNED_DELETE',
|
||||||
COMMENTS: {
|
COMMENTS: {
|
||||||
ADD: 'MATCHES_OWNED_COMMENTS_ADD',
|
ADD: 'MATCHES_OWNED_COMMENTS_ADD',
|
||||||
DELETE: 'MATCHES_OWNED_COMMENTS_DELETE',
|
DELETE: 'MATCHES_OWNED_COMMENTS_DELETE',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
UNOWNED: {
|
PARTICIPANT: {
|
||||||
READ: 'MATCHES_UNOWNED_READ',
|
READ: 'MATCHES_PARTICIPANT_READ',
|
||||||
UPDATE: 'MATCHES_UNOWNED_UPDATE',
|
LEAVE: 'MATCHES_LEAVE',
|
||||||
DELETE: 'MATCHES_UNOWNED_DELETE',
|
|
||||||
COMMENTS: {
|
COMMENTS: {
|
||||||
ADD: 'MATCHES_UNOWNED_COMMENTS_ADD',
|
ADD: 'MATCHES_PARTICIPANT_COMMENTS_ADD',
|
||||||
DELETE: 'MATCHES_UNOWNED_COMMENTS_DELETE',
|
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
public static readonly COLLECTIONS = {
|
public static readonly COLLECTIONS = {
|
||||||
CREATE: 'COLLECTIONS_CREATE',
|
CREATE: 'COLLECTIONS_CREATE',
|
||||||
|
|||||||
@@ -1,32 +1,26 @@
|
|||||||
import { orderBy } from 'lodash';
|
import { orderBy } from 'lodash';
|
||||||
|
import { MatchParticipant } from '../orm/matches';
|
||||||
|
|
||||||
interface GamePlayer {
|
export function calculateElos(players: MatchParticipant[], provisionalPeriod: number = 1): MatchParticipant[] {
|
||||||
id: string;
|
const orderedResults = orderBy(players, (x:any) => parseInt(x.standing ?? 0), 'asc');
|
||||||
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');
|
|
||||||
for (let i = 0; i < orderedResults.length - 1; i++) {
|
for (let i = 0; i < orderedResults.length - 1; i++) {
|
||||||
for (let j = i + 1; j < orderedResults.length; j++) {
|
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 =
|
||||||
(orderedResults[i].eloChange ?? 0) +
|
(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 =
|
||||||
(orderedResults[j].eloChange ?? 0) +
|
(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 {
|
return orderedResults;
|
||||||
players: orderedResults,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EloResult {
|
interface EloResult {
|
||||||
|
|||||||
@@ -50,3 +50,7 @@ export interface UpdateCollectionRequest {
|
|||||||
export interface GameToCollectionRequest {
|
export interface GameToCollectionRequest {
|
||||||
gameId: string;
|
gameId: string;
|
||||||
}
|
}
|
||||||
|
export interface CreateMatchRequest {
|
||||||
|
gameId: string;
|
||||||
|
participants: { playerId: string; standing: number }[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BadRequestError, NotFoundError, UnauthorizedError } from './errors';
|
import { BadRequestError, NotFoundError, UnauthorizedError } from './errors';
|
||||||
import { clamp, isArray } from 'lodash';
|
import { clamp, isObject } from 'lodash';
|
||||||
import { UnwrappedRequest } from './guard';
|
import { UnwrappedRequest } from './guard';
|
||||||
|
|
||||||
export class ErrorResponse extends Response {
|
export class ErrorResponse extends Response {
|
||||||
@@ -52,11 +52,11 @@ export class OkResponse extends Response {
|
|||||||
constructor(body?: any) {
|
constructor(body?: any) {
|
||||||
if (body) {
|
if (body) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
isArray(body)
|
isObject(body)
|
||||||
? body
|
? {
|
||||||
: {
|
|
||||||
...body,
|
...body,
|
||||||
},
|
}
|
||||||
|
: body,
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -122,3 +122,15 @@ export class CollectionId extends SecureId {
|
|||||||
return super.fromID(id, CollectionId);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user