Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ node_modules
.dist
.eslintcache
config/*
.DS_Store
.DS_Store
servers.inc.php
176 changes: 147 additions & 29 deletions src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as url from 'url';
import { Config } from './config-loader';
import { Ladder, type LadderEntry } from './ladder';
import { Replays } from './replays';
import { ActionError, type QueryHandler, Server, DISPATCH_PREFIX} from './server';
import { ActionError, type QueryHandler, Server, DISPATCH_PREFIX } from './server';
import { Session } from './user';
import {
toID, updateserver, bash, time, escapeHTML, signAsync, TimeSorter,
Expand All @@ -28,6 +28,16 @@ export interface Suspect {
elo: number | null;
}

export interface SuspectParticipation {
entryid: number;
start_date: number;
userid: string;
w: number;
l: number;
t: number;
qualified: 0 | 1;
}

const OAUTH_TOKEN_TIME = 2 * 7 * 24 * 60 * 60 * 1000;

async function getOAuthClient(clientId?: string, origin?: string) {
Expand Down Expand Up @@ -106,10 +116,21 @@ export const smogonFetch = async (targetUrl: string, method: string, data: { [k:
});
};

export function checkSuspectVerified(
export async function checkSuspectVerified(
rating: LadderEntry,
suspect: Suspect
) {
let wltData: { w: number, l: number, t: number } | null;
if ((rating?.first_played && rating.first_played > suspect.start_date)) {
// did not play games before the test began
wltData = rating;
} else {
wltData = await tables.suspectParticipation.selectOne()`WHERE formatid = ${suspect.formatid}
AND start_date = ${suspect.start_date} AND userid = ${rating.userid}` || null;
}

if (!wltData) return false;

let reqsMet = 0;
let reqCount = 0;
const userData: Partial<{ elo: number, gxe: number, coil: number }> = {};
Expand All @@ -120,7 +141,7 @@ export function checkSuspectVerified(
reqCount++;
switch (k) {
case 'coil':
const N = rating.w + rating.l + rating.t;
const N = wltData.w + wltData.l + wltData.t;
const coilNum = Math.round(40.0 * rating.gxe * (2.0 ** (-coil[suspect.formatid] / N)));
if (coilNum >= suspect.coil!) {
reqsMet++;
Expand All @@ -137,9 +158,7 @@ export function checkSuspectVerified(
}
if (
// sanity check for reqs existing just to be totally safe
(reqsMet > 0 && reqsMet === reqCount) &&
// did not play games before the test began
(rating?.first_played && rating.first_played > suspect.start_date)
(reqsMet > 0 && reqsMet === reqCount)
) {
void smogonFetch("tools/api/suspect-verify", "POST", {
userid: rating.userid,
Expand All @@ -150,6 +169,19 @@ export function checkSuspectVerified(
},
suspectStartDate: suspect.start_date,
});

if (wltData === rating) {
void tables.suspectParticipation.insert({
start_date: suspect.start_date,
userid: rating.userid,
w: rating.w,
l: rating.l,
t: rating.t,
qualified: 1,
});
} else {
void tables.suspectParticipation.update((wltData as SuspectParticipation).entryid, { qualified: 1 });
}
return true;
}
return false;
Expand All @@ -163,6 +195,33 @@ function exportTeam(team: string) {
return Teams.export(teamData);
}

export async function trackSuspectParticipation(
rating: LadderEntry | null,
suspect: Suspect,
score?: number
) {
if (!rating) return;
let particpation = await tables.suspectParticipation.selectOne()`WHERE formatid = ${suspect.formatid}
AND start_date = ${suspect.start_date} AND userid = ${rating.userid}`;
const createEntry = score === undefined;
if (createEntry) {
// create new entry for new participant
// (or reset an existing entry if RD has been reset since it was created)
particpation = {
start_date: suspect.start_date,
userid: rating.userid,
w: 0,
l: 0,
t: 0,
qualified: 0,
} as SuspectParticipation;
}
if (particpation && !particpation.qualified) {
if (!createEntry) particpation[Ladder.scoreToKey(score)]++;
await tables.suspectParticipation.upsert(particpation);
}
}

export const actions: { [k: string]: QueryHandler } = {
async register(params) {
this.verifyCrossDomainRequest();
Expand Down Expand Up @@ -435,11 +494,11 @@ export const actions: { [k: string]: QueryHandler } = {
},

async ladderupdate(params) {
const server = await this.getServer(true);
if (server?.id !== Config.mainserver) {
// legacy error
return { errorip: this.getIp() };
}
// const server = await this.getServer(true);
// if (server?.id !== Config.mainserver) {
// // legacy error
// return { errorip: this.getIp() };
// }

const formatid = toID(params.format);
if (!formatid) throw new ActionError("Invalid format.");
Expand All @@ -449,12 +508,17 @@ export const actions: { [k: string]: QueryHandler } = {
if (!Ladder.isValidPlayer(params.p2)) return 0;

const out: { [k: string]: any } = {};
const [p1rating, p2rating] = await ladder.addMatch(params.p1!, params.p2!, parseFloat(params.score));

const suspect = await tables.suspects.get(formatid);
const scores = Ladder.expandP1Score(parseFloat(params.score));
if (suspect) {
await trackSuspectParticipation(await ladder.getRating(params.p1!), suspect, scores[0]);
await trackSuspectParticipation(await ladder.getRating(params.p2!), suspect, scores[1]);
}
const [p1rating, p2rating] = await ladder.addMatch(params.p1!, params.p2!, ...scores);
if (suspect) {
for (const rating of [p1rating, p2rating]) {
checkSuspectVerified(rating, suspect);
await checkSuspectVerified(rating, suspect);
}
}
out.actionsuccess = true;
Expand All @@ -477,6 +541,16 @@ export const actions: { [k: string]: QueryHandler } = {
const suspect = await tables.suspects.get(rating.formatid);
if (suspect) {
rating.suspect = !!rating.first_played && rating.first_played > suspect.start_date;
if (!rating.suspect) {
const participation = await tables.suspectParticipation.selectOne()`WHERE
formatid = ${suspect.formatid} AND
start_date = ${suspect.start_date} AND
userid = ${rating.userid}`;
if (participation) {
rating.suspect = true;
[rating.w, rating.l, rating.t] = [participation.w, participation.l, participation.t];
}
}
}
}
return ratings;
Expand Down Expand Up @@ -1257,6 +1331,46 @@ export const actions: { [k: string]: QueryHandler } = {
return { ips: times.toJSON() };
},

async 'suspects/join'(params) {
await this.requireMainServer();
if (this.getIp() !== Config.restartip) {
throw new ActionError("Access denied.");
}
const formatid = toID(params.format);
if (!formatid) throw new ActionError("No format ID specified.");
const userid = toID(params.user);
if (!userid) {
throw new ActionError("User not specified.");
}

// no RD reset version
const suspect = await tables.suspects.get(formatid);
if (!suspect) {
throw new ActionError(`There is no suspect test in ${formatid}`);
} else if (!suspect.coil) {
throw new ActionError(`This command is only available for tests with COIL requirements.`);
}
const participationData = await tables.suspectParticipation.selectOne()`WHERE userid = ${userid} AND
formatid = ${formatid} AND start_date = ${suspect.start_date}`;
if (participationData) {
if (participationData.qualified) {
throw new ActionError('This account has already qualified to vote in this suspect test!');
// it would be nice to show the user a URL that takes them to voting in this case
} else {
throw new ActionError('This account has already been made eligible to participate in this suspect test.');
}
}
const user = await tables.ladder.selectOne()`WHERE userid = ${userid} AND formatid = ${formatid}`;
if (!user?.first_played || user.first_played >= suspect.start_date) {
// don't track participation for accounts without activity before the suspect
// there's no reason we should need to, it saves on space,
// and otherwise this system would have broken ongoing suspect tests when it was introduced
throw new ActionError('This account is already eligible to participate in this suspect test.');
}

await trackSuspectParticipation(user, suspect);
},

async 'suspects/add'(params) {
await this.requireMainServer();
if (this.getIp() !== Config.restartip) {
Expand All @@ -1278,16 +1392,18 @@ export const actions: { [k: string]: QueryHandler } = {
}
const start = time();
let out;
try {
const res = await smogonFetch("tools/api/suspect-create", "POST", {
date: `${start}`,
reqs,
format: id,
});
if (!res) throw new Error('failed');
out = await res.json();
} catch (e: any) {
throw new ActionError("Failed to update Smogon suspect test record: " + e.message);
if (id !== 'gen5randombattle') {
try {
const res = await smogonFetch("tools/api/suspect-create", "POST", {
date: `${start}`,
reqs,
format: id,
});
if (!res) throw new Error('failed');
out = await res.json();
} catch (e: any) {
throw new ActionError("Failed to update Smogon suspect test record: " + e.message);
}
}
const existing = await tables.suspects.get(id);
if (existing) {
Expand All @@ -1305,7 +1421,7 @@ export const actions: { [k: string]: QueryHandler } = {
coil: reqs.coil || null,
});
}
return { success: true, url: (out as any).url };
return { success: true, url: (out as any)?.url };
},
async 'suspects/edit'(params) {
await this.requireMainServer();
Expand Down Expand Up @@ -1353,10 +1469,12 @@ export const actions: { [k: string]: QueryHandler } = {
const suspect = await tables.suspects.get(id);
if (!suspect) throw new ActionError("There is no ongoing suspect for " + id);
await tables.suspects.delete(id);
await smogonFetch("tools/api/suspect-end", "POST", {
formatid: id,
time: suspect.start_date,
});
if (id !== 'gen5randombattle') {
await smogonFetch("tools/api/suspect-end", "POST", {
formatid: id,
time: suspect.start_date,
});
}
return { success: true };
},
async 'suspects/verify'(params) {
Expand All @@ -1373,7 +1491,7 @@ export const actions: { [k: string]: QueryHandler } = {
const rating = await new Ladder(id).getRating(userid);
if (!rating) throw new ActionError("That user has no ratings in the given ladder.");
return {
result: checkSuspectVerified(rating, suspect),
result: await checkSuspectVerified(rating, suspect),
};
},
};
Expand Down
4 changes: 2 additions & 2 deletions src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export class SQLStatement {
this.append(value[col], `, `);
}
this.sql[this.sql.length - 1] = this.sql[this.sql.length - 1].slice(0, -2) + nextString;
} else if (this.sql[this.sql.length - 1].toUpperCase().endsWith(' SET ')) {
} else if ([' SET ', ' UPDATE '].some(x => this.sql[this.sql.length - 1].toUpperCase().endsWith(x))) {
// "`a` = 1, `b` = 2" syntax
this.sql[this.sql.length - 1] += `"`;
for (const col in value) {
Expand Down Expand Up @@ -271,7 +271,7 @@ export class DatabaseTable<Row, DB extends Database> {
}) DO UPDATE ${partialUpdate as any} ${where}`;
}
return this.queryExec(
)`INSERT INTO "${this.name}" (${partialRow as any}) ON DUPLICATE KEY UPDATE ${partialUpdate as any} ${where}`;
)`INSERT INTO "${this.name}" (${partialRow as any}) ON DUPLICATE KEY UPDATE ${partialUpdate as any}`;
}
set(primaryKey: BasicSQLValue, partialRow: PartialOrSQL<Row>, where?: SQLStatement) {
if (!this.primaryKeyName) throw new Error(`Cannot set() without a single-column primary key`);
Expand Down
28 changes: 17 additions & 11 deletions src/ladder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ const RP_OFFSET = 9 * 60 * 60;
const GLICKO_RD_MAX = 130.0;
const GLICKO_RD_MIN = 25.0;

// this solves for going from min RD to max RD in 365 days
const GLICKO_C = Math.sqrt((GLICKO_RD_MAX ** 2 - GLICKO_RD_MIN ** 2) / (365.0 / RP_LENGTH_DAYS));

export interface LadderEntry {
Expand Down Expand Up @@ -273,13 +272,7 @@ export class Ladder {
}
if (newM) {
glicko.m.push(newM);
if (newM.score > 0.99) {
rating.w++;
} else if (newM.score < 0.01) {
rating.l++;
} else {
rating.t++;
}
rating[Ladder.scoreToKey(newM.score)]++;
rating.col1++;
}

Expand Down Expand Up @@ -339,12 +332,11 @@ export class Ladder {

return true;
}
async addMatch(player1: string, player2: string, p1score: number) {
async addMatch(player1: string, player2: string, p1score: number, p2score?: number) {
const p1 = await this.getRating(player1, true);
const p2 = await this.getRating(player2, true);

let p2score = 1 - p1score;
if (p1score < 0) [p1score, p2score] = [0, 0];
if (!p2score) [p1score, p2score] = Ladder.expandP1Score(p1score);

const p1M = new GlickoPlayer(p2.r, p2.rd).matchElement(p1score)[0];
const p2M = new GlickoPlayer(p1.r, p1.rd).matchElement(p2score)[0];
Expand All @@ -362,6 +354,20 @@ export class Ladder {
if (userid.length > 18 || !userid) return null;
return userid;
}
static expandP1Score(p1score: number): [number, number] {
let p2score = 1 - p1score;
if (p1score < 0) [p1score, p2score] = [0, 0];
return [p1score, p2score];
}
static scoreToKey(score: number) {
if (score > 0.99) {
return 'w';
} else if (score < 0.01) {
return 'l';
} else {
return 't';
}
}
}

export class GlickoPlayer {
Expand Down
3 changes: 3 additions & 0 deletions src/schemas/ntbb-ladder.sql
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ CREATE TABLE IF NOT EXISTS `ntbb_ladder` (
`rpdata` mediumblob NOT NULL,
`elo` double NOT NULL DEFAULT '1000',
`col1` double NOT NULL,
`oldelo` double NOT NULL DEFAULT '1000',
`first_played` bigint(11) NOT NULL,
`last_played` bigint(11) NOT NULL,
PRIMARY KEY (`entryid`),
UNIQUE KEY `userformats` (`userid`,`formatid`),
KEY `formattoplist` (`formatid`,`elo`)
Expand Down
12 changes: 12 additions & 0 deletions src/schemas/ntbb-suspectparticipation.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- Table structure for suspect participation tracking

CREATE TABLE `ntbb_suspect_participation` (
entryid int NOT NULL PRIMARY KEY AUTO_INCREMENT,
start_date bigint(20) NOT NULL,
userid varchar(18) NOT NULL,
w int,
l int,
t int,
qualified bool,
UNIQUE KEY `startuser` (`start_date`,`userid`)
) AUTO_INCREMENT=1;
Loading
Loading