From bf0975b6c3ce0d23b52cc9819097020e2bde506a Mon Sep 17 00:00:00 2001 From: pyuk-bot <21160928+pyuk-bot@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:26:43 -0500 Subject: [PATCH 1/6] Add support for old accounts participating in suspect tests --- src/actions.ts | 130 +++++++++++++++++----- src/ladder.ts | 67 ++++++++--- src/schemas/ntbb-suspectparticipation.sql | 12 ++ src/tables.ts | 4 +- src/test/actions.test.ts | 77 +++++++++++++ 5 files changed, 251 insertions(+), 39 deletions(-) create mode 100644 src/schemas/ntbb-suspectparticipation.sql diff --git a/src/actions.ts b/src/actions.ts index 58c13d8..fc99b92 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -9,7 +9,7 @@ import * as pathModule from 'path'; import * as crypto from 'crypto'; import * as url from 'url'; import { Config } from './config-loader'; -import { Ladder, type LadderEntry } from './ladder'; +import { GLICKO_RD_MAX, Ladder, type LadderEntry } from './ladder'; import { Replays } from './replays'; import { ActionError, type QueryHandler, Server, DISPATCH_PREFIX} from './server'; import { Session } from './user'; @@ -28,6 +28,17 @@ export interface Suspect { elo: number | null; } +export interface SuspectParticipation { + entryid: number; + formatid: string; + start_date: number; + userid: string; + w: number; + l: number; + t: number; + qualified: boolean; +} + const OAUTH_TOKEN_TIME = 2 * 7 * 24 * 60 * 60 * 1000; async function getOAuthClient(clientId?: string, origin?: string) { @@ -106,10 +117,25 @@ export const smogonFetch = async (targetUrl: string, method: string, data: { [k: }); }; -export function checkSuspectVerified( +export async function checkSuspectVerified( rating: LadderEntry, suspect: Suspect ) { + // sanity check + // a player with maxed rprd has definitely played no games during the test + if (rating.rprd >= GLICKO_RD_MAX) return false; + + 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 }> = {}; @@ -120,7 +146,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++; @@ -137,9 +163,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, @@ -150,6 +174,20 @@ export function checkSuspectVerified( }, suspectStartDate: suspect.start_date, }); + + if (wltData === rating) { + void tables.suspectParticipation.insert({ + formatid: suspect.formatid, + start_date: suspect.start_date, + userid: rating.userid, + w: rating.w, + l: rating.l, + t: rating.t, + qualified: true, + }); + } else { + void tables.suspectParticipation.update((wltData as SuspectParticipation).entryid, {qualified: true}); + } return true; } return false; @@ -163,6 +201,35 @@ function exportTeam(team: string) { return Teams.export(teamData); } +export async function trackSuspectParticipation( + rating: LadderEntry | null, + score: number, + suspect: Suspect, +) { + if (!rating) return; + let particpation = await tables.suspectParticipation.selectOne()`WHERE formatid = ${suspect.formatid} + AND start_date = ${suspect.start_date} AND userid = ${rating.userid}`; + if (rating.rprd >= GLICKO_RD_MAX && (suspect.coil || suspect.gxe)) { + // create new entry for new participant + // (or reset an existing entry if RD has been reset since it was created) + particpation = { + formatid: suspect.formatid, + start_date: suspect.start_date, + userid: rating.userid, + w: 0, + l: 0, + t: 0, + qualified: false, + } as SuspectParticipation; + } + if (particpation && !particpation.qualified) { + particpation[Ladder.scoreToKey(score)]++; + await tables.suspectParticipation.upsert(particpation, particpation, + SQL`WHERE formatid = ${suspect.formatid} + AND start_date = ${suspect.start_date} AND userid = ${rating.userid}`); + } +} + export const actions: { [k: string]: QueryHandler } = { async register(params) { this.verifyCrossDomainRequest(); @@ -449,12 +516,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!), scores[0], suspect); + await trackSuspectParticipation(await ladder.getRating(params.p2!), scores[1], suspect); + } + 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; @@ -472,11 +544,15 @@ export const actions: { [k: string]: QueryHandler } = { const user = Ladder.isValidPlayer(params.user); if (!user) throw new ActionError("Invalid username."); - const ratings = await Ladder.getAllRatings(user) as (LadderEntry & { suspect?: boolean })[]; + const ratings = await Ladder.getAllRatings(user) as (LadderEntry & { suspect: boolean | SuspectParticipation })[]; for (const rating of ratings) { const suspect = await tables.suspects.get(rating.formatid); if (suspect) { rating.suspect = !!rating.first_played && rating.first_played > suspect.start_date; + if (!rating.suspect) { + rating.suspect = await tables.suspectParticipation.selectOne()`WHERE formatid = ${suspect.formatid} + AND start_date = ${suspect.start_date} AND userid = ${rating.userid}` || false; + } } } return ratings; @@ -1278,16 +1354,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) { @@ -1305,7 +1383,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(); @@ -1353,10 +1431,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) { @@ -1373,7 +1453,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), }; }, }; diff --git a/src/ladder.ts b/src/ladder.ts index a827923..3c79653 100644 --- a/src/ladder.ts +++ b/src/ladder.ts @@ -6,7 +6,8 @@ */ import { toID, time } from './utils'; -import { ladder } from './tables'; +import { ladder, suspects } from './tables'; +import { ActionError } from './server'; /** length of a rating period in days (used for Glicko and Elo decay). * Glickman recommends 5-10 games per rating period */ @@ -16,10 +17,9 @@ const RP_LENGTH = 24 * 60 * 60 * RP_LENGTH_DAYS; /** time in UTC rating periods roll over, in seconds (9am UTC, or 4am Chicago Time) */ const RP_OFFSET = 9 * 60 * 60; -const GLICKO_RD_MAX = 130.0; +export 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 { @@ -92,6 +92,36 @@ export class Ladder { w: 0, l: 0, t: 0, })`WHERE userid = ${toID(name)} AND formatid = ${this.formatid}`; } + async resetRD(name: string) { + const suspect = await suspects.get(this.formatid); + if (!suspect) { + throw new ActionError('This command is only available during a suspect test.'); + } + const user = await ladder.selectOne()`WHERE userid = ${toID(name)} AND formatid = ${this.formatid}`; + if (!user?.first_played || user.first_played >= suspect.start_date) { + // don't allow accounts without activity before the suspect to reset RD + // there's no reason they 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.'); + } + if (user.rd >= GlickoPlayer.RDmax) { + // don't allow accounts to spam this command + throw new ActionError( + 'This account is already eligible to participate in this suspect test, ' + + 'or it has already used this command today.' + ); + } + if (JSON.parse(user.rpdata.split('##')[0]).length) { + // user has pending match data; resetting RD now would mess up how their rating is calculated + throw new ActionError('You have played rated games in this format today. Please try again tomorrow.'); + // alternatively, we could force-update their glicko rating now instead, + // because the previous check is enough to throttle this command to once per day + } + + return ladder.updateOne({ + rd: GlickoPlayer.RDmax, rprd: GlickoPlayer.RDmax, // r: user.rpr, rpdata: JSON.stringify([]), + })`WHERE userid = ${toID(name)} AND formatid = ${this.formatid}`; + } getRating(user: string): Promise; getRating(user: string, create: true): Promise; async getRating(user: string, create = false): Promise { @@ -273,13 +303,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++; } @@ -339,12 +363,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]; @@ -362,6 +385,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 { @@ -369,6 +406,9 @@ export class GlickoPlayer { rd: number; readonly piSquared = Math.PI ** 2; + static RDmax = 130.0; + static RDmin = 25.0; + c: number; readonly q = 0.00575646273; m: MatchElement[] = []; @@ -376,6 +416,7 @@ export class GlickoPlayer { // Step 1 this.rating = rating; this.rd = rd; + this.c = Math.sqrt((GlickoPlayer.RDmax * GlickoPlayer.RDmax - GlickoPlayer.RDmin * GlickoPlayer.RDmin) / 365.0); } addWin(otherPlayer: GlickoPlayer) { diff --git a/src/schemas/ntbb-suspectparticipation.sql b/src/schemas/ntbb-suspectparticipation.sql new file mode 100644 index 0000000..67f8795 --- /dev/null +++ b/src/schemas/ntbb-suspectparticipation.sql @@ -0,0 +1,12 @@ +-- Table structure for suspect participation tracking + +CREATE TABLE `ntbb_suspect_participation` ( + entryid int NOT NULL PRIMARY KEY AUTO_INCREMENT, + formatid varchar(100) NOT NULL, + start_date bigint(20) NOT NULL, + userid varchar(18) NOT NULL, + w int, + l int, + t int, + qualified bool +) AUTO_INCREMENT=1; diff --git a/src/tables.ts b/src/tables.ts index e1db615..9b93e07 100644 --- a/src/tables.ts +++ b/src/tables.ts @@ -6,7 +6,7 @@ import { Config } from './config-loader'; import type { LadderEntry } from './ladder'; import type { ReplayRow } from './replays'; -import type { Suspect } from './actions'; +import type { SuspectParticipation, Suspect } from './actions'; // direct access export const psdb = new MySQLDatabase(Config.mysql); @@ -146,3 +146,5 @@ export const teams = pgdb.getTable<{ }>('teams', 'teamid'); export const suspects = psdb.getTable("suspects", 'formatid'); + +export const suspectParticipation = psdb.getTable("suspect_participation", 'entryid'); diff --git a/src/test/actions.test.ts b/src/test/actions.test.ts index 94b3748..7517b9e 100644 --- a/src/test/actions.test.ts +++ b/src/test/actions.test.ts @@ -8,6 +8,7 @@ import { Ladder } from '../ladder'; import { toID } from '../utils'; import * as utils from './test-utils'; import * as tables from '../tables'; +import {ActionError} from '../server'; const token = '42354y6dhgfdsretr'; describe('Loginserver actions', () => { @@ -143,5 +144,81 @@ describe('Loginserver actions', () => { assert.strictEqual(p1r.elo, result, `Expected elo ${p1r.elo}, got ${result}`); }); + + it('Should track suspect test participation', async () => { + const ladder = new Ladder('gen5randombattle'); + const p1 = 'shera'; + const p2 = 'catra'; + for (const player of [p1, p2]) { + await tables.ladder.deleteAll()`WHERE userid = ${toID(player)} AND formatid = ${ladder.formatid}`; + await tables.suspectParticipation.deleteAll()`WHERE userid = ${toID(player)} AND formatid = ${ladder.formatid}`; + } + await tables.suspects.delete(ladder.formatid); + let suspectStarted = await utils.testDispatcher({ + act: 'suspects/add', + format: ladder.formatid, + reqs: JSON.stringify({ + gxe: 1800, + }), + serverid: 'showdown', + servertoken: token, + }); + assert(suspectStarted.result.success); + + await ladder.addMatch(p1, p2, 1); + + assert( + !(await tables.suspectParticipation.selectOne()`WHERE userid = ${p1} AND formatid = ${ladder.formatid}`), + 'Suspect participation data should not be tracked separately for accounts new to the format', + ); + assert.throws(async () => await ladder.resetRD(p1), new ActionError('This account is already eligible to participate in this suspect test.')); + + await utils.testDispatcher({ + act: 'suspects/end', + format: ladder.formatid, + serverid: 'showdown', + servertoken: token, + }); + suspectStarted = await utils.testDispatcher({ + act: 'suspects/add', + format: ladder.formatid, + reqs: JSON.stringify({ + gxe: 1800, + }), + serverid: 'showdown', + servertoken: token, + }); + assert(suspectStarted.result.success); + const suspect = await tables.suspects.get(ladder.formatid); + + await ladder.addMatch(p1, p2, 1); + + assert( + !(await tables.suspectParticipation.selectOne()`WHERE userid = ${p1} AND formatid = ${ladder.formatid}`), + 'Suspect participation data should not be tracked for accounts not new to the format that have not had their RD reset', + ); + await ladder.resetRD(p1); + + await ladder.addMatch(p1, p2, 1); + + const participation = await tables.suspectParticipation.selectOne()`WHERE userid = ${p1} AND formatid = ${ladder.formatid}`; + assert( + !!participation, + 'Suspect participation data should be tracked for accounts not new to the format that have had their RD reset', + ); + assert.deepStrictEqual( + participation, + { + entryid: participation!.entryid, + formatid: ladder.formatid, + start_date: suspect!.start_date, + userid: p1, + w: 1, + l: 0, + t: 0, + qualified: false, + } + ); + }); }); }); From 08d111107e1c4137ec3a70ae47594f5d4cdd1a04 Mon Sep 17 00:00:00 2001 From: pyuk-bot <21160928+pyuk-bot@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:01:06 -0500 Subject: [PATCH 2/6] Replace w/l/t in ladderget during a suspect --- src/actions.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index fc99b92..01693cf 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -544,14 +544,20 @@ export const actions: { [k: string]: QueryHandler } = { const user = Ladder.isValidPlayer(params.user); if (!user) throw new ActionError("Invalid username."); - const ratings = await Ladder.getAllRatings(user) as (LadderEntry & { suspect: boolean | SuspectParticipation })[]; + const ratings = await Ladder.getAllRatings(user) as (LadderEntry & { suspect?: boolean })[]; for (const rating of ratings) { const suspect = await tables.suspects.get(rating.formatid); if (suspect) { rating.suspect = !!rating.first_played && rating.first_played > suspect.start_date; if (!rating.suspect) { - rating.suspect = await tables.suspectParticipation.selectOne()`WHERE formatid = ${suspect.formatid} - AND start_date = ${suspect.start_date} AND userid = ${rating.userid}` || false; + 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]; + } } } } From 1f2b422e9c03178509660f944c69c92044574a68 Mon Sep 17 00:00:00 2001 From: pyuk-bot <21160928+pyuk-bot@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:41:08 -0500 Subject: [PATCH 3/6] Fix formatting --- src/actions.ts | 8 ++++---- src/test/actions.test.ts | 18 +++++++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index 01693cf..50e90fd 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -11,7 +11,7 @@ import * as url from 'url'; import { Config } from './config-loader'; import { GLICKO_RD_MAX, 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, @@ -125,7 +125,7 @@ export async function checkSuspectVerified( // a player with maxed rprd has definitely played no games during the test if (rating.rprd >= GLICKO_RD_MAX) return false; - let wltData: {w: number, l: number, t: number} | null; + 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; @@ -186,7 +186,7 @@ export async function checkSuspectVerified( qualified: true, }); } else { - void tables.suspectParticipation.update((wltData as SuspectParticipation).entryid, {qualified: true}); + void tables.suspectParticipation.update((wltData as SuspectParticipation).entryid, { qualified: true }); } return true; } @@ -204,7 +204,7 @@ function exportTeam(team: string) { export async function trackSuspectParticipation( rating: LadderEntry | null, score: number, - suspect: Suspect, + suspect: Suspect ) { if (!rating) return; let particpation = await tables.suspectParticipation.selectOne()`WHERE formatid = ${suspect.formatid} diff --git a/src/test/actions.test.ts b/src/test/actions.test.ts index 7517b9e..c8c3273 100644 --- a/src/test/actions.test.ts +++ b/src/test/actions.test.ts @@ -8,7 +8,7 @@ import { Ladder } from '../ladder'; import { toID } from '../utils'; import * as utils from './test-utils'; import * as tables from '../tables'; -import {ActionError} from '../server'; +import { ActionError } from '../server'; const token = '42354y6dhgfdsretr'; describe('Loginserver actions', () => { @@ -169,9 +169,12 @@ describe('Loginserver actions', () => { assert( !(await tables.suspectParticipation.selectOne()`WHERE userid = ${p1} AND formatid = ${ladder.formatid}`), - 'Suspect participation data should not be tracked separately for accounts new to the format', + 'Suspect participation data should not be tracked separately for accounts new to the format' + ); + assert.throws( + async () => await ladder.resetRD(p1), + new ActionError('This account is already eligible to participate in this suspect test.') ); - assert.throws(async () => await ladder.resetRD(p1), new ActionError('This account is already eligible to participate in this suspect test.')); await utils.testDispatcher({ act: 'suspects/end', @@ -195,21 +198,22 @@ describe('Loginserver actions', () => { assert( !(await tables.suspectParticipation.selectOne()`WHERE userid = ${p1} AND formatid = ${ladder.formatid}`), - 'Suspect participation data should not be tracked for accounts not new to the format that have not had their RD reset', + 'Suspect participation data should not be tracked for accounts not new to the format that have not had their RD reset' ); await ladder.resetRD(p1); await ladder.addMatch(p1, p2, 1); - const participation = await tables.suspectParticipation.selectOne()`WHERE userid = ${p1} AND formatid = ${ladder.formatid}`; + const participation = await tables.suspectParticipation.selectOne()`WHERE userid = ${p1} AND + formatid = ${ladder.formatid}`; assert( !!participation, - 'Suspect participation data should be tracked for accounts not new to the format that have had their RD reset', + 'Suspect participation data should be tracked for accounts not new to the format that have had their RD reset' ); assert.deepStrictEqual( participation, { - entryid: participation!.entryid, + entryid: participation.entryid, formatid: ladder.formatid, start_date: suspect!.start_date, userid: p1, From 22ba50a08f71d38ee28b68ece6b674b2e9efc9fe Mon Sep 17 00:00:00 2001 From: pyuk-bot <21160928+pyuk-bot@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:40:48 -0500 Subject: [PATCH 4/6] Fix bugs --- .gitignore | 3 +- src/actions.ts | 33 ++++++++++++--------- src/database.ts | 4 +-- src/ladder.ts | 35 +++++++++++++---------- src/schemas/ntbb-ladder.sql | 3 ++ src/schemas/ntbb-suspectparticipation.sql | 3 +- 6 files changed, 49 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index 1a8d12c..d5dd22c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules .dist .eslintcache config/* -.DS_Store \ No newline at end of file +.DS_Store +servers.inc.php diff --git a/src/actions.ts b/src/actions.ts index 50e90fd..8c93aed 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -36,7 +36,7 @@ export interface SuspectParticipation { w: number; l: number; t: number; - qualified: boolean; + qualified: 0 | 1; } const OAUTH_TOKEN_TIME = 2 * 7 * 24 * 60 * 60 * 1000; @@ -183,10 +183,10 @@ export async function checkSuspectVerified( w: rating.w, l: rating.l, t: rating.t, - qualified: true, + qualified: 1, }); } else { - void tables.suspectParticipation.update((wltData as SuspectParticipation).entryid, { qualified: true }); + void tables.suspectParticipation.update((wltData as SuspectParticipation).entryid, { qualified: 1 }); } return true; } @@ -219,14 +219,12 @@ export async function trackSuspectParticipation( w: 0, l: 0, t: 0, - qualified: false, + qualified: 0, } as SuspectParticipation; } if (particpation && !particpation.qualified) { particpation[Ladder.scoreToKey(score)]++; - await tables.suspectParticipation.upsert(particpation, particpation, - SQL`WHERE formatid = ${suspect.formatid} - AND start_date = ${suspect.start_date} AND userid = ${rating.userid}`); + await tables.suspectParticipation.upsert(particpation); } } @@ -502,11 +500,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."); @@ -1339,11 +1337,20 @@ export const actions: { [k: string]: QueryHandler } = { return { ips: times.toJSON() }; }, - async 'suspects/add'(params) { + async 'suspects/join'(params) { await this.requireMainServer(); if (this.getIp() !== Config.restartip) { throw new ActionError("Access denied."); } + const id = toID(params.format); + if (!id) throw new ActionError("No format ID specified."); + if (!params.user) { + throw new ActionError("User not specified."); + } + await new Ladder(id).resetRD(params.user); + }, + + async 'suspects/add'(params) { const id = toID(params.format); if (!id) throw new ActionError("No format ID specified."); if (!params.reqs) { diff --git a/src/database.ts b/src/database.ts index dbde1ef..5e59c6c 100644 --- a/src/database.ts +++ b/src/database.ts @@ -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) { @@ -271,7 +271,7 @@ export class DatabaseTable { }) 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, where?: SQLStatement) { if (!this.primaryKeyName) throw new Error(`Cannot set() without a single-column primary key`); diff --git a/src/ladder.ts b/src/ladder.ts index 3c79653..42c08d9 100644 --- a/src/ladder.ts +++ b/src/ladder.ts @@ -6,7 +6,7 @@ */ import { toID, time } from './utils'; -import { ladder, suspects } from './tables'; +import { ladder, suspectParticipation, suspects } from './tables'; import { ActionError } from './server'; /** length of a rating period in days (used for Glicko and Elo decay). @@ -95,7 +95,7 @@ export class Ladder { async resetRD(name: string) { const suspect = await suspects.get(this.formatid); if (!suspect) { - throw new ActionError('This command is only available during a suspect test.'); + throw new ActionError(`There is no suspect test in ${this.formatid}`); } const user = await ladder.selectOne()`WHERE userid = ${toID(name)} AND formatid = ${this.formatid}`; if (!user?.first_played || user.first_played >= suspect.start_date) { @@ -104,23 +104,32 @@ export class Ladder { // 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.'); } - if (user.rd >= GlickoPlayer.RDmax) { + const hasRPData = !!JSON.parse(user.rpdata.split('##')[0]).length; + const hasParticipationData = await suspectParticipation.selectOne()`WHERE userid = ${toID(name)} AND + formatid = ${this.formatid} AND start_date = ${suspect.start_date}`; + if (hasRPData && hasParticipationData) { + // user has pending match data; resetting RD now would mess up how their rating is calculated + throw new ActionError('You have played rated games in this format today. Please try again tomorrow.'); + } + if (user.rd >= GLICKO_RD_MAX && (hasParticipationData || !hasRPData)) { // don't allow accounts to spam this command throw new ActionError( 'This account is already eligible to participate in this suspect test, ' + 'or it has already used this command today.' ); } - if (JSON.parse(user.rpdata.split('##')[0]).length) { - // user has pending match data; resetting RD now would mess up how their rating is calculated - throw new ActionError('You have played rated games in this format today. Please try again tomorrow.'); - // alternatively, we could force-update their glicko rating now instead, - // because the previous check is enough to throttle this command to once per day + const update: Partial = { rd: GLICKO_RD_MAX, rprd: GLICKO_RD_MAX }; + if (hasRPData) { + // to allow accounts to begin participating the day the suspect starts, + // if an account has rpdata but no participation data, + // we just roll their pending glicko r value into their "official" one early and clear their rpdata + // it would be bad to do this too often, since it adds one extra day of drift towards the max value of RD + // but once per suspect should be ok + update.r = user.rpr; + update.rpdata = JSON.stringify([]); } - return ladder.updateOne({ - rd: GlickoPlayer.RDmax, rprd: GlickoPlayer.RDmax, // r: user.rpr, rpdata: JSON.stringify([]), - })`WHERE userid = ${toID(name)} AND formatid = ${this.formatid}`; + return ladder.updateOne(update)`WHERE userid = ${toID(name)} AND formatid = ${this.formatid}`; } getRating(user: string): Promise; getRating(user: string, create: true): Promise; @@ -406,9 +415,6 @@ export class GlickoPlayer { rd: number; readonly piSquared = Math.PI ** 2; - static RDmax = 130.0; - static RDmin = 25.0; - c: number; readonly q = 0.00575646273; m: MatchElement[] = []; @@ -416,7 +422,6 @@ export class GlickoPlayer { // Step 1 this.rating = rating; this.rd = rd; - this.c = Math.sqrt((GlickoPlayer.RDmax * GlickoPlayer.RDmax - GlickoPlayer.RDmin * GlickoPlayer.RDmin) / 365.0); } addWin(otherPlayer: GlickoPlayer) { diff --git a/src/schemas/ntbb-ladder.sql b/src/schemas/ntbb-ladder.sql index f05dcb5..650d8e4 100644 --- a/src/schemas/ntbb-ladder.sql +++ b/src/schemas/ntbb-ladder.sql @@ -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`) diff --git a/src/schemas/ntbb-suspectparticipation.sql b/src/schemas/ntbb-suspectparticipation.sql index 67f8795..28d54bb 100644 --- a/src/schemas/ntbb-suspectparticipation.sql +++ b/src/schemas/ntbb-suspectparticipation.sql @@ -8,5 +8,6 @@ CREATE TABLE `ntbb_suspect_participation` ( w int, l int, t int, - qualified bool + qualified bool, + UNIQUE KEY `formatstartuser` (`formatid`,`start_date`,`userid`) ) AUTO_INCREMENT=1; From 199fb249cf94f9b7b58af7b834d512a1b48a9e84 Mon Sep 17 00:00:00 2001 From: pyuk-bot <21160928+pyuk-bot@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:13:50 -0500 Subject: [PATCH 5/6] Fix more bugs --- src/actions.ts | 2 +- src/ladder.ts | 33 +++++++++++++++++++++++---------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index 8c93aed..90ccb4e 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -209,7 +209,7 @@ export async function trackSuspectParticipation( if (!rating) return; let particpation = await tables.suspectParticipation.selectOne()`WHERE formatid = ${suspect.formatid} AND start_date = ${suspect.start_date} AND userid = ${rating.userid}`; - if (rating.rprd >= GLICKO_RD_MAX && (suspect.coil || suspect.gxe)) { + if (rating.rprd >= GLICKO_RD_MAX && (suspect.coil)) { // create new entry for new participant // (or reset an existing entry if RD has been reset since it was created) particpation = { diff --git a/src/ladder.ts b/src/ladder.ts index 42c08d9..36037c2 100644 --- a/src/ladder.ts +++ b/src/ladder.ts @@ -96,6 +96,13 @@ export class Ladder { const suspect = await suspects.get(this.formatid); if (!suspect) { throw new ActionError(`There is no suspect test in ${this.formatid}`); + } else if (!suspect.coil) { + throw new ActionError(`This command is only available for tests with COIL requirements.`); + } + const participationData = await suspectParticipation.selectOne()`WHERE userid = ${toID(name)} AND + formatid = ${this.formatid} AND start_date = ${suspect.start_date}`; + if (participationData?.qualified) { + throw new ActionError('You have already qualified to vote in this suspect test!'); } const user = await ladder.selectOne()`WHERE userid = ${toID(name)} AND formatid = ${this.formatid}`; if (!user?.first_played || user.first_played >= suspect.start_date) { @@ -104,32 +111,38 @@ export class Ladder { // 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.'); } - const hasRPData = !!JSON.parse(user.rpdata.split('##')[0]).length; - const hasParticipationData = await suspectParticipation.selectOne()`WHERE userid = ${toID(name)} AND - formatid = ${this.formatid} AND start_date = ${suspect.start_date}`; - if (hasRPData && hasParticipationData) { + let hasRPData = true; + + if (this.getRP() > user.rptime) { + // if the user's rating is out of date, update it to get current RD and clear pending match data + this.update(user); + hasRPData = false; + } + hasRPData = hasRPData && !!JSON.parse(user.rpdata.split('##')[0]).length; + if (hasRPData && participationData) { // user has pending match data; resetting RD now would mess up how their rating is calculated throw new ActionError('You have played rated games in this format today. Please try again tomorrow.'); } - if (user.rd >= GLICKO_RD_MAX && (hasParticipationData || !hasRPData)) { + if (user.rd >= GLICKO_RD_MAX && (participationData || !hasRPData)) { // don't allow accounts to spam this command throw new ActionError( 'This account is already eligible to participate in this suspect test, ' + 'or it has already used this command today.' ); } - const update: Partial = { rd: GLICKO_RD_MAX, rprd: GLICKO_RD_MAX }; + + user.rd = user.rprd = GLICKO_RD_MAX; if (hasRPData) { // to allow accounts to begin participating the day the suspect starts, // if an account has rpdata but no participation data, // we just roll their pending glicko r value into their "official" one early and clear their rpdata - // it would be bad to do this too often, since it adds one extra day of drift towards the max value of RD + // it could be bad to do this too often, since it would reduce the accuracy of Glicko ratings (probably?) // but once per suspect should be ok - update.r = user.rpr; - update.rpdata = JSON.stringify([]); + user.r = user.rpr; + user.rpdata = JSON.stringify([]); } - return ladder.updateOne(update)`WHERE userid = ${toID(name)} AND formatid = ${this.formatid}`; + return ladder.updateOne(user)`WHERE userid = ${toID(name)} AND formatid = ${this.formatid}`; } getRating(user: string): Promise; getRating(user: string, create: true): Promise; From 96a1967e2ee4f6feb0c6916c63842b877c06621e Mon Sep 17 00:00:00 2001 From: pyuk-bot <21160928+pyuk-bot@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:42:56 -0500 Subject: [PATCH 6/6] Remove RD reset --- src/actions.ts | 61 ++++++++++++----- src/ladder.ts | 57 +--------------- src/schemas/ntbb-suspectparticipation.sql | 3 +- src/schemas/ntbb-suspects.sql | 2 +- src/test/actions.test.ts | 81 ----------------------- 5 files changed, 47 insertions(+), 157 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index 90ccb4e..5992539 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -9,7 +9,7 @@ import * as pathModule from 'path'; import * as crypto from 'crypto'; import * as url from 'url'; import { Config } from './config-loader'; -import { GLICKO_RD_MAX, Ladder, type LadderEntry } from './ladder'; +import { Ladder, type LadderEntry } from './ladder'; import { Replays } from './replays'; import { ActionError, type QueryHandler, Server, DISPATCH_PREFIX } from './server'; import { Session } from './user'; @@ -30,7 +30,6 @@ export interface Suspect { export interface SuspectParticipation { entryid: number; - formatid: string; start_date: number; userid: string; w: number; @@ -121,10 +120,6 @@ export async function checkSuspectVerified( rating: LadderEntry, suspect: Suspect ) { - // sanity check - // a player with maxed rprd has definitely played no games during the test - if (rating.rprd >= GLICKO_RD_MAX) return false; - 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 @@ -177,7 +172,6 @@ export async function checkSuspectVerified( if (wltData === rating) { void tables.suspectParticipation.insert({ - formatid: suspect.formatid, start_date: suspect.start_date, userid: rating.userid, w: rating.w, @@ -203,17 +197,17 @@ function exportTeam(team: string) { export async function trackSuspectParticipation( rating: LadderEntry | null, - score: number, - suspect: Suspect + 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}`; - if (rating.rprd >= GLICKO_RD_MAX && (suspect.coil)) { + 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 = { - formatid: suspect.formatid, start_date: suspect.start_date, userid: rating.userid, w: 0, @@ -223,7 +217,7 @@ export async function trackSuspectParticipation( } as SuspectParticipation; } if (particpation && !particpation.qualified) { - particpation[Ladder.scoreToKey(score)]++; + if (!createEntry) particpation[Ladder.scoreToKey(score)]++; await tables.suspectParticipation.upsert(particpation); } } @@ -518,8 +512,8 @@ export const actions: { [k: string]: QueryHandler } = { const suspect = await tables.suspects.get(formatid); const scores = Ladder.expandP1Score(parseFloat(params.score)); if (suspect) { - await trackSuspectParticipation(await ladder.getRating(params.p1!), scores[0], suspect); - await trackSuspectParticipation(await ladder.getRating(params.p2!), scores[1], 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) { @@ -1342,15 +1336,46 @@ export const actions: { [k: string]: QueryHandler } = { if (this.getIp() !== Config.restartip) { throw new ActionError("Access denied."); } - const id = toID(params.format); - if (!id) throw new ActionError("No format ID specified."); - if (!params.user) { + 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."); } - await new Ladder(id).resetRD(params.user); + + // 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) { + throw new ActionError("Access denied."); + } const id = toID(params.format); if (!id) throw new ActionError("No format ID specified."); if (!params.reqs) { diff --git a/src/ladder.ts b/src/ladder.ts index 36037c2..8839a26 100644 --- a/src/ladder.ts +++ b/src/ladder.ts @@ -6,8 +6,7 @@ */ import { toID, time } from './utils'; -import { ladder, suspectParticipation, suspects } from './tables'; -import { ActionError } from './server'; +import { ladder } from './tables'; /** length of a rating period in days (used for Glicko and Elo decay). * Glickman recommends 5-10 games per rating period */ @@ -17,7 +16,7 @@ const RP_LENGTH = 24 * 60 * 60 * RP_LENGTH_DAYS; /** time in UTC rating periods roll over, in seconds (9am UTC, or 4am Chicago Time) */ const RP_OFFSET = 9 * 60 * 60; -export const GLICKO_RD_MAX = 130.0; +const GLICKO_RD_MAX = 130.0; const GLICKO_RD_MIN = 25.0; const GLICKO_C = Math.sqrt((GLICKO_RD_MAX ** 2 - GLICKO_RD_MIN ** 2) / (365.0 / RP_LENGTH_DAYS)); @@ -92,58 +91,6 @@ export class Ladder { w: 0, l: 0, t: 0, })`WHERE userid = ${toID(name)} AND formatid = ${this.formatid}`; } - async resetRD(name: string) { - const suspect = await suspects.get(this.formatid); - if (!suspect) { - throw new ActionError(`There is no suspect test in ${this.formatid}`); - } else if (!suspect.coil) { - throw new ActionError(`This command is only available for tests with COIL requirements.`); - } - const participationData = await suspectParticipation.selectOne()`WHERE userid = ${toID(name)} AND - formatid = ${this.formatid} AND start_date = ${suspect.start_date}`; - if (participationData?.qualified) { - throw new ActionError('You have already qualified to vote in this suspect test!'); - } - const user = await ladder.selectOne()`WHERE userid = ${toID(name)} AND formatid = ${this.formatid}`; - if (!user?.first_played || user.first_played >= suspect.start_date) { - // don't allow accounts without activity before the suspect to reset RD - // there's no reason they 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.'); - } - let hasRPData = true; - - if (this.getRP() > user.rptime) { - // if the user's rating is out of date, update it to get current RD and clear pending match data - this.update(user); - hasRPData = false; - } - hasRPData = hasRPData && !!JSON.parse(user.rpdata.split('##')[0]).length; - if (hasRPData && participationData) { - // user has pending match data; resetting RD now would mess up how their rating is calculated - throw new ActionError('You have played rated games in this format today. Please try again tomorrow.'); - } - if (user.rd >= GLICKO_RD_MAX && (participationData || !hasRPData)) { - // don't allow accounts to spam this command - throw new ActionError( - 'This account is already eligible to participate in this suspect test, ' + - 'or it has already used this command today.' - ); - } - - user.rd = user.rprd = GLICKO_RD_MAX; - if (hasRPData) { - // to allow accounts to begin participating the day the suspect starts, - // if an account has rpdata but no participation data, - // we just roll their pending glicko r value into their "official" one early and clear their rpdata - // it could be bad to do this too often, since it would reduce the accuracy of Glicko ratings (probably?) - // but once per suspect should be ok - user.r = user.rpr; - user.rpdata = JSON.stringify([]); - } - - return ladder.updateOne(user)`WHERE userid = ${toID(name)} AND formatid = ${this.formatid}`; - } getRating(user: string): Promise; getRating(user: string, create: true): Promise; async getRating(user: string, create = false): Promise { diff --git a/src/schemas/ntbb-suspectparticipation.sql b/src/schemas/ntbb-suspectparticipation.sql index 28d54bb..fff664e 100644 --- a/src/schemas/ntbb-suspectparticipation.sql +++ b/src/schemas/ntbb-suspectparticipation.sql @@ -2,12 +2,11 @@ CREATE TABLE `ntbb_suspect_participation` ( entryid int NOT NULL PRIMARY KEY AUTO_INCREMENT, - formatid varchar(100) NOT NULL, start_date bigint(20) NOT NULL, userid varchar(18) NOT NULL, w int, l int, t int, qualified bool, - UNIQUE KEY `formatstartuser` (`formatid`,`start_date`,`userid`) + UNIQUE KEY `startuser` (`start_date`,`userid`) ) AUTO_INCREMENT=1; diff --git a/src/schemas/ntbb-suspects.sql b/src/schemas/ntbb-suspects.sql index 416377b..5f17003 100644 --- a/src/schemas/ntbb-suspects.sql +++ b/src/schemas/ntbb-suspects.sql @@ -4,7 +4,7 @@ CREATE TABLE `ntbb_suspects` ( formatid varchar(100) NOT NULL PRIMARY KEY, - start_date bigint(20) NOT NULL, + start_date bigint(20) NOT NULL UNIQUE KEY, elo int, coil int, gxe int diff --git a/src/test/actions.test.ts b/src/test/actions.test.ts index c8c3273..94b3748 100644 --- a/src/test/actions.test.ts +++ b/src/test/actions.test.ts @@ -8,7 +8,6 @@ import { Ladder } from '../ladder'; import { toID } from '../utils'; import * as utils from './test-utils'; import * as tables from '../tables'; -import { ActionError } from '../server'; const token = '42354y6dhgfdsretr'; describe('Loginserver actions', () => { @@ -144,85 +143,5 @@ describe('Loginserver actions', () => { assert.strictEqual(p1r.elo, result, `Expected elo ${p1r.elo}, got ${result}`); }); - - it('Should track suspect test participation', async () => { - const ladder = new Ladder('gen5randombattle'); - const p1 = 'shera'; - const p2 = 'catra'; - for (const player of [p1, p2]) { - await tables.ladder.deleteAll()`WHERE userid = ${toID(player)} AND formatid = ${ladder.formatid}`; - await tables.suspectParticipation.deleteAll()`WHERE userid = ${toID(player)} AND formatid = ${ladder.formatid}`; - } - await tables.suspects.delete(ladder.formatid); - let suspectStarted = await utils.testDispatcher({ - act: 'suspects/add', - format: ladder.formatid, - reqs: JSON.stringify({ - gxe: 1800, - }), - serverid: 'showdown', - servertoken: token, - }); - assert(suspectStarted.result.success); - - await ladder.addMatch(p1, p2, 1); - - assert( - !(await tables.suspectParticipation.selectOne()`WHERE userid = ${p1} AND formatid = ${ladder.formatid}`), - 'Suspect participation data should not be tracked separately for accounts new to the format' - ); - assert.throws( - async () => await ladder.resetRD(p1), - new ActionError('This account is already eligible to participate in this suspect test.') - ); - - await utils.testDispatcher({ - act: 'suspects/end', - format: ladder.formatid, - serverid: 'showdown', - servertoken: token, - }); - suspectStarted = await utils.testDispatcher({ - act: 'suspects/add', - format: ladder.formatid, - reqs: JSON.stringify({ - gxe: 1800, - }), - serverid: 'showdown', - servertoken: token, - }); - assert(suspectStarted.result.success); - const suspect = await tables.suspects.get(ladder.formatid); - - await ladder.addMatch(p1, p2, 1); - - assert( - !(await tables.suspectParticipation.selectOne()`WHERE userid = ${p1} AND formatid = ${ladder.formatid}`), - 'Suspect participation data should not be tracked for accounts not new to the format that have not had their RD reset' - ); - await ladder.resetRD(p1); - - await ladder.addMatch(p1, p2, 1); - - const participation = await tables.suspectParticipation.selectOne()`WHERE userid = ${p1} AND - formatid = ${ladder.formatid}`; - assert( - !!participation, - 'Suspect participation data should be tracked for accounts not new to the format that have had their RD reset' - ); - assert.deepStrictEqual( - participation, - { - entryid: participation.entryid, - formatid: ladder.formatid, - start_date: suspect!.start_date, - userid: p1, - w: 1, - l: 0, - t: 0, - qualified: false, - } - ); - }); }); });