From 088d35ad7adf04c644d2a9275e26b1fa911e20fb Mon Sep 17 00:00:00 2001 From: "Alexander B." <4866817+MathyFurret@users.noreply.github.com> Date: Tue, 19 Mar 2024 01:04:48 -0500 Subject: [PATCH 1/4] Add [Gen 9] Draft Factory --- config/formats.ts | 16 +++++++ data/draft-factory.ts | 68 ++++++++++++++++++++++++++++ sim/teams.ts | 2 + test/random-battles/draft-factory.js | 54 ++++++++++++++++++++++ 4 files changed, 140 insertions(+) create mode 100644 data/draft-factory.ts create mode 100644 test/random-battles/draft-factory.js diff --git a/config/formats.ts b/config/formats.ts index 5974c73c38ca..628473a82f8d 100644 --- a/config/formats.ts +++ b/config/formats.ts @@ -2494,6 +2494,22 @@ export const Formats: import('../sim/dex-formats').FormatList = [ } }, }, + { + name: "[Gen 9] Draft Factory", + desc: `Replay a random matchup from Smogon's Draft League tournaments.`, + team: 'draft', + ruleset: ['Obtainable', 'Species Clause', 'HP Percentage Mod', 'Cancel Mod', 'Team Preview', 'Sleep Clause Mod', 'Endless Battle Clause'], + onBegin() { + for (const [i, side] of this.sides.entries()) { + // Order of team is not changed from the data doc + for (const [j, set] of this.teamGenerator.matchup[i].entries()) { + if (!set.teraCaptain) { + side.pokemon[j].canTerastallize = false; + } + } + } + }, + }, { name: "[Gen 8] Random Battle", desc: `Randomized teams of level-balanced Pokémon with sets that are generated to be competitively viable.`, diff --git a/data/draft-factory.ts b/data/draft-factory.ts new file mode 100644 index 000000000000..0367cdebec14 --- /dev/null +++ b/data/draft-factory.ts @@ -0,0 +1,68 @@ +import {PRNG} from "../sim/prng"; +import {deepClone} from "../lib/utils"; + +interface DraftPokemonSet extends Partial { + teraCaptain?: boolean; +} + +const sampleData: [DraftPokemonSet[], DraftPokemonSet[]][] = [ + [ + [ + { + name: 'Fred', + species: 'Furret', + item: 'Choice Scarf', + ability: 'Frisk', + moves: ['trick', 'doubleedge', 'knockoff', 'uturn'], + nature: 'Jolly', + evs: {hp: 8, atk: 252, def: 0, spa: 0, spd: 0, spe: 252}, + teraCaptain: true, + teraType: 'Normal', + }, + ], + [ + { + species: 'Ampharos', + item: 'Choice Specs', + ability: 'Static', + moves: ['dazzlinggleam', 'thunderbolt', 'focusblast', 'voltswitch'], + nature: 'Modest', + evs: {hp: 248, atk: 0, def: 8, spa: 252, spd: 0, spe: 0}, + }, + ], + ], +]; + +export default class DraftFactory { + dex: ModdedDex; + format: Format; + prng: PRNG; + matchup?: [DraftPokemonSet[], DraftPokemonSet[]]; + playerIndex: number; + swapTeams: boolean; + constructor(format: Format | string, seed: PRNG | PRNGSeed | null) { + this.dex = Dex.forFormat(format); + this.format = Dex.formats.get(format); + this.prng = seed instanceof PRNG ? seed : new PRNG(seed); + this.playerIndex = 0; + this.swapTeams = this.prng.randomChance(1, 2); + } + + setSeed(seed: PRNGSeed) { + this.prng.seed = seed; + } + + getTeam(options?: PlayerOptions | null): PokemonSet[] { + if (this.playerIndex > 1) throw new Error("Can't generate more than 2 teams"); + + if (!this.matchup) { + this.matchup = deepClone(sampleData[this.prng.next(sampleData.length)]); + if (this.swapTeams) this.matchup!.push(this.matchup!.shift()!); + } + + const team: PokemonSet[] = this.matchup![this.playerIndex] as PokemonSet[]; + + this.playerIndex++; + return team; + } +} diff --git a/sim/teams.ts b/sim/teams.ts index 0b39a6f67108..dfb44c084463 100644 --- a/sim/teams.ts +++ b/sim/teams.ts @@ -620,6 +620,8 @@ export const Teams = new class Teams { format = Dex.formats.get(format); if (toID(format).includes('gen9computergeneratedteams')) { TeamGenerator = require(Dex.forFormat(format).dataDir + '/cg-teams').default; + } else if (toID(format).includes('gen9draftfactory')) { + TeamGenerator = require(Dex.forFormat(format).dataDir + '/draft-factory').default; } else if (toID(format).includes('gen9superstaffbrosultimate')) { TeamGenerator = require(`../data/mods/gen9ssb/random-teams`).default; } else if (toID(format).includes('gen9babyrandombattle')) { diff --git a/test/random-battles/draft-factory.js b/test/random-battles/draft-factory.js new file mode 100644 index 000000000000..f1292b46a8d1 --- /dev/null +++ b/test/random-battles/draft-factory.js @@ -0,0 +1,54 @@ +'use strict'; + +const assert = require('../assert'); +const common = require('../common'); +const DraftFactory = require('../../dist/data/draft-factory').default; + +let battle; + +describe.only('Draft Factory', () => { + afterEach(() => battle.destroy()); + + it('should only allow the designated Tera Captains to Terastallize', () => { + battle = common.createBattle({formatid: 'gen9draftfactory'}); + // Manually create a team generator instance and rig it with data + battle.teamGenerator = new DraftFactory(battle.format, null); + battle.teamGenerator.swapTeams = false; + battle.teamGenerator.matchup = [ + [ + { + species: 'Furret', + ability: 'keeneye', + moves: ['sleeptalk'], + teraCaptain: true, + teraType: 'Normal', + }, + { + species: 'Ampharos', + ability: 'static', + moves: ['sleeptalk'], + }, + ], + [ + { + species: 'Nincada', + ability: 'compoundeyes', + moves: ['sleeptalk'], + }, + { + species: 'Marshtomp', + ability: 'torrent', + moves: ['sleeptalk'], + teraCaptain: true, + teraType: 'Fighting', + }, + ], + ]; + battle.setPlayer('p1', {}); + battle.setPlayer('p2', {}); + battle.makeChoices(); // team preview + assert.throws(() => { battle.choose('p2', 'move 1 terastallize'); }, `${battle.p2.pokemon[0].name} should not be able to tera`); + battle.makeChoices('move 1 terastallize', 'switch 2'); + battle.makeChoices('auto', 'move 1 terastallize'); + }); +}); From fe5f517359e044120e8b6af352ea086ce705b311 Mon Sep 17 00:00:00 2001 From: "Alexander B." <4866817+MathyFurret@users.noreply.github.com> Date: Tue, 19 Mar 2024 01:08:38 -0500 Subject: [PATCH 2/4] oops --- test/random-battles/draft-factory.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/random-battles/draft-factory.js b/test/random-battles/draft-factory.js index f1292b46a8d1..ac846143696d 100644 --- a/test/random-battles/draft-factory.js +++ b/test/random-battles/draft-factory.js @@ -6,7 +6,7 @@ const DraftFactory = require('../../dist/data/draft-factory').default; let battle; -describe.only('Draft Factory', () => { +describe('Draft Factory', () => { afterEach(() => battle.destroy()); it('should only allow the designated Tera Captains to Terastallize', () => { From c658b2e4d04bb963a82d9b83643c03fdb5ceda10 Mon Sep 17 00:00:00 2001 From: "Alexander B." <4866817+MathyFurret@users.noreply.github.com> Date: Mon, 29 Jul 2024 02:13:03 -0500 Subject: [PATCH 3/4] Implement basic set import functionality --- databases/schemas/draft-factory.sql | 17 +++ server/chat-plugins/draft-factory.ts | 162 +++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 databases/schemas/draft-factory.sql create mode 100644 server/chat-plugins/draft-factory.ts diff --git a/databases/schemas/draft-factory.sql b/databases/schemas/draft-factory.sql new file mode 100644 index 000000000000..e527b3bd5c26 --- /dev/null +++ b/databases/schemas/draft-factory.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS draftfactory_sources ( + id TEXT PRIMARY KEY, + source_url TEXT NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS gen9draftfactory ( + source TEXT NOT NULL, + url1 TEXT NOT NULL, + team1 TEXT NOT NULL, + url2 TEXT NOT NULL, + team2 TEXT NOT NULL, + FOREIGN KEY (source) REFERENCES draftfactory_sources(id) ON DELETE CASCADE, + UNIQUE (url1, url2) +); + +DROP VIEW IF EXISTS draftfactory_sources_count; +CREATE VIEW draftfactory_sources_count AS SELECT source, COUNT(*) AS count FROM gen9draftfactory GROUP BY source; \ No newline at end of file diff --git a/server/chat-plugins/draft-factory.ts b/server/chat-plugins/draft-factory.ts new file mode 100644 index 000000000000..bd7a4c144ae8 --- /dev/null +++ b/server/chat-plugins/draft-factory.ts @@ -0,0 +1,162 @@ +/** + * Tools for managing the Draft Factory database. + * + * @author MathyFurret + */ + +import {SQL, Net} from "../../lib"; +import {Teams} from "../../sim"; + +interface DraftPokemonSet extends Partial { + teraCaptain?: boolean; +} + +/** + * Given a PokePaste URL, outputs the URL to access the raw text. + * + * This assumes the URL given is either a normal PokePaste URL or already a URL to the raw text. + */ +function getRawURL(url: string): string { + if (url.endsWith('/raw')) return url; + return url + '/raw'; +} + +/** + * Given a Showdown team export, prepares a stringified JSON of the team. + * Handles extensions used in Draft, such as marking Tera Captains. + */ +function prepareTeamJSON(paste: string): string { + const sets: DraftPokemonSet[] | null = Teams.import(paste); + if (!sets) throw new Error("Could not parse paste"); + for (const set of sets) { + if (set.name === "Tera Captain") { + set.name = ''; + set.teraCaptain = true; + } + } + return JSON.stringify(sets); +} + +class DraftFactoryDB { + db?: SQL.DatabaseManager; + ready: boolean; + + constructor() { + this.ready = false; + if (!Config.usesqlite) return; + this.db = SQL(module, { + file: './databases/draft-factory.db', + }); + void this.setupDatabase(); + } + + async setupDatabase() { + if (!this.db) return; + await this.db.runFile('./databases/schemas/draft-factory.sql'); + this.ready = true; + } + + async getSourcesCount(): Promise<{source: string, count: number}[]> { + if (!this.db || !this.ready) return []; + return this.db.all(`SELECT * FROM draftfactory_sources_count`); + } + + /** + * Loads an external CSV file containing PokePaste URLs and adds teams to the database. + * Associates the teams with a source named `sourceName`. + * + * A line in the wrong format will cause a rollback. + * An HTTP error or team parsing error will simply skip the line. + */ + async loadCSV(url: string, sourceName: ID): Promise { + if (!this.db || !this.ready) throw new Chat.ErrorMessage("Can't load teams; the DB isn't ready"); + await this.db.run(`BEGIN`); + try { + await this.db.run(`INSERT INTO draftfactory_sources (id, source_url) VALUES (?, ?)`, [sourceName, url]); + const insertStatement = await this.db.prepare(`INSERT OR ABORT INTO gen9draftfactory (source, url1, team1, url2, team2) VALUES (?, ?, ?, ?, ?)`); + if (!insertStatement) throw new Chat.ErrorMessage("Couldn't parse the insert statement"); + const stream = Net(url).getStream(); + let line: string | null; + let skipHeader = true; + const pendingAdds = []; + const errors: Error[] = []; + while ((line = await stream.readLine()) !== null) { + if (skipHeader) { + skipHeader = false; + continue; + } + if (!line.trim()) continue; + // player1 name, team url, pokemon, pokemon, player2 name, team url, pokemon, pokemon + const [, url1, , , , url2] = line.split(','); + if (!url1 || !url2) throw new Chat.ErrorMessage("Unexpected format"); + const requests = [Net(getRawURL(url1)).get(), Net(getRawURL(url2)).get()]; + // pendingRequests.push(...requests); + pendingAdds.push((async () => { + try { + let [paste1, paste2] = await Promise.all(requests); + paste1 = prepareTeamJSON(paste1); + paste2 = prepareTeamJSON(paste2); + await insertStatement.run([sourceName, url1, paste1, url2, paste2]); + } catch (e: any) { + errors.push(e); + } + })()); + } + await Promise.all(pendingAdds); + await this.db.run(`COMMIT`); + return errors; + } catch (e) { + await this.db.run(`ROLLBACK`); + throw e; + } + } + + async deleteSource(sourceName: ID) { + if (!this.db || !this.ready) throw new Error("The DB isn't ready"); + await this.db.run(`DELETE FROM draftfactory_sources WHERE id = ?`, [sourceName]); + } +} + +async function setupDatabase(database: SQL.DatabaseManager) { + await database.runFile('./databases/schemas/draft-factory.sql'); +} + +const db: DraftFactoryDB | null = Config.usesqlite ? new DraftFactoryDB() : null; + +const WHITELIST = ['mathy']; + +function check(ctx: Chat.CommandContext) { + if (!WHITELIST.includes(ctx.user.id)) ctx.checkCan('rangeban'); + if (!db) throw new Chat.ErrorMessage(`This feature is not supported because SQLite is disabled.`); +} + +export const commands: Chat.ChatCommands = { + draftfactory: { + async import(target) { + check(this); + const args = target.split(','); + if (args.length !== 2) throw new Chat.ErrorMessage(`This command takes exactly 2 arguments.`); + const url = args[0].trim(); + const label = toID(args[1]); + const errors = await db!.loadCSV(url, label); + if (errors.length) { + this.errorReply(`Encountered ${errors.length} ${Chat.plural(errors, 'error')}; other sets imported successfully.`); + } else { + this.sendReply(`All sets imported successfully.`); + } + }, + importhelp: [ + `/draftfactory import url, label - Imports a CSV of Draft Factory teams from the given URL.`, + `The teams will be associated with a label (must be unique); you can delete teams from this label with /draftfactory delete.`, + ], + async delete(target) { + check(this); + db!.deleteSource(toID(target)); + }, + }, + draftfactoryhelp: [ + `/draftfactory import url, label - Imports a CSV of Draft Factory teams from the given URL.`, + `/draftfactory delete label - Deletes all teams under the name "label"`, + `Requires: &`, + ] +}; \ No newline at end of file From fa2b028f9f99dc14a2c8dacbf04391eed05f190a Mon Sep 17 00:00:00 2001 From: Kris Johnson <11083252+KrisXV@users.noreply.github.com> Date: Mon, 29 Jul 2024 10:23:10 -0600 Subject: [PATCH 4/4] Update server/chat-plugins/draft-factory.ts --- server/chat-plugins/draft-factory.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/chat-plugins/draft-factory.ts b/server/chat-plugins/draft-factory.ts index bd7a4c144ae8..7bfd6ee4c5a1 100644 --- a/server/chat-plugins/draft-factory.ts +++ b/server/chat-plugins/draft-factory.ts @@ -158,5 +158,5 @@ export const commands: Chat.ChatCommands = { `/draftfactory import url, label - Imports a CSV of Draft Factory teams from the given URL.`, `/draftfactory delete label - Deletes all teams under the name "label"`, `Requires: &`, - ] -}; \ No newline at end of file + ], +};