diff --git a/build-tools/build-indexes b/build-tools/build-indexes index 6285e88b8..f862a84e3 100755 --- a/build-tools/build-indexes +++ b/build-tools/build-indexes @@ -1400,8 +1400,9 @@ process.stdout.write("Building `data/moves,items,abilities,typechart,learnsets.j const Moves = requireNoCache('../caches/pokemon-showdown/dist/data/moves.js').Moves; for (const id in Moves) { const move = Dex.moves.get(Moves[id].name); - if (move.desc) Moves[id].desc = move.desc; - if (move.shortDesc) Moves[id].shortDesc = move.shortDesc; + const text = Dex.text.get(move); + if (text.desc) Moves[id].desc = text.desc; + if (text.shortDesc) Moves[id].shortDesc = text.shortDesc; if (move.basePowerCallback) Moves[id].basePowerCallback = true; } const buf = 'exports.BattleMovedex = ' + es3stringify(Moves) + ';'; @@ -1417,8 +1418,9 @@ process.stdout.write("Building `data/moves,items,abilities,typechart,learnsets.j const Items = requireNoCache('../caches/pokemon-showdown/dist/data/items.js').Items; for (const id in Items) { const item = Dex.items.get(Items[id].name); - if (item.desc) Items[id].desc = item.desc; - if (item.shortDesc) Items[id].shortDesc = item.shortDesc; + const text = Dex.text.get(item); + if (text.desc) Items[id].desc = text.desc; + if (text.shortDesc) Items[id].shortDesc = text.shortDesc; } const buf = 'exports.BattleItems = ' + es3stringify(Items) + ';'; fs.writeFileSync('play.pokemonshowdown.com/data/items.js', buf); @@ -1432,8 +1434,9 @@ process.stdout.write("Building `data/moves,items,abilities,typechart,learnsets.j const Abilities = requireNoCache('../caches/pokemon-showdown/dist/data/abilities.js').Abilities; for (const id in Abilities) { const ability = Dex.abilities.get(Abilities[id].name); - if (ability.desc) Abilities[id].desc = ability.desc; - if (ability.shortDesc) Abilities[id].shortDesc = ability.shortDesc; + const text = Dex.text.get(ability); + if (text.desc) Abilities[id].desc = text.desc; + if (text.shortDesc) Abilities[id].shortDesc = text.shortDesc; } const buf = 'exports.BattleAbilities = ' + es3stringify(Abilities) + ';'; fs.writeFileSync('play.pokemonshowdown.com/data/abilities.js', buf); @@ -1491,34 +1494,24 @@ process.stdout.write("Building `data/moves,items,abilities,typechart,learnsets.j } /********************************************************* - * Build text.js + * Build text/*.js *********************************************************/ { - const textData = Dex.loadTextData(); - const Text = textData.Default; + const LANGS = fs.readdirSync('caches/pokemon-showdown/data/text', { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .concat('en'); - function assignData(id, entry) { - for (const key in entry) { - if (['name', 'desc', 'shortDesc'].includes(key)) continue; - const textEntry = Text[id] || (Text[id] = {}); - if (key.startsWith('gen')) { - for (const modKey in entry[key]) { - if (['desc', 'shortDesc'].includes(key)) continue; - textEntry[modKey + 'Gen' + key.charAt(3)] = entry[key][modKey]; - } - } else { - textEntry[key] = entry[key]; - } - } + fs.mkdirSync('play.pokemonshowdown.com/data/text', { recursive: true }); + for (const lang of LANGS) { + const translation = Dex.loadTextData(lang); + fs.writeFileSync( + `play.pokemonshowdown.com/data/text/${lang}.js`, + 'exports.BattleText = exports.BattleText || {};\n' + + `exports.BattleText[${JSON.stringify(lang)}] = ` + es3stringify(translation) + ';' + ); } - - for (const id in textData.Moves) assignData(id, textData.Moves[id]); - for (const id in textData.Abilities) assignData(id, textData.Abilities[id]); - for (const id in textData.Items) assignData(id, textData.Items[id]); - - const buf = 'exports.BattleText = ' + es3stringify(Text) + ';'; - fs.writeFileSync('play.pokemonshowdown.com/data/text.js', buf); } console.log("DONE"); diff --git a/build-tools/update b/build-tools/update index 7e31da195..aeb83e464 100755 --- a/build-tools/update +++ b/build-tools/update @@ -114,8 +114,8 @@ compiledFiles += compiler.compileToDir(`replay.pokemonshowdown.com/src`, `replay compiledFiles += compiler.compileToDir(`teams.pokemonshowdown.com/src`, `teams.pokemonshowdown.com/js`, compileOpts); try { - fs.statSync('play.pokemonshowdown.com/data/text.js'); - // text.js exists, we can compile battledata + fs.statSync('play.pokemonshowdown.com/data/text/en.js'); + // English text exists, we can compile battledata compiledFiles += compiler.compileToFile( [ 'play.pokemonshowdown.com/src/battle-dex.ts', @@ -124,8 +124,7 @@ try { 'play.pokemonshowdown.com/src/battle-log.ts', 'play.pokemonshowdown.com/src/battle-log-misc.js', 'caches/pokemon-showdown/server/chat-formatter.ts', - 'play.pokemonshowdown.com/data/text.js', - 'play.pokemonshowdown.com/data/text-afd.js', + 'play.pokemonshowdown.com/data/text/en.js', 'play.pokemonshowdown.com/src/battle-text-parser.ts', ], 'play.pokemonshowdown.com/js/battledata.js', diff --git a/eslint-ps-standard.mjs b/eslint-ps-standard.mjs index 4494a6bfe..c4cca46fe 100644 --- a/eslint-ps-standard.mjs +++ b/eslint-ps-standard.mjs @@ -349,7 +349,11 @@ export const defaultRulesES3TSChecked = { // Map/Set can be polyfilled but it's nontrivial and it's easier just to use bare objects // fetch can be polyfilled, but our standard is to use $.get or Net as appropriate. "no-restricted-globals": ["error", "Proxy", "Reflect", "Symbol", "WeakSet", "WeakMap", "Set", "Map", "fetch"], - "no-restricted-syntax": ["error", "YieldExpression", "AwaitExpression", "BigIntLiteral"], + "no-restricted-syntax": ["error", + "YieldExpression", "AwaitExpression", "BigIntLiteral", + { selector: "MethodDefinition[kind='get']", message: "Getters cannot be compiled to ES3" }, + { selector: "Property[kind='get']", message: "Getters cannot be compiled to ES3" }, + ], }; /** diff --git a/play.pokemonshowdown.com/data/text-afd.js b/play.pokemonshowdown.com/data/text-afd.js deleted file mode 100644 index ac47a558b..000000000 --- a/play.pokemonshowdown.com/data/text-afd.js +++ /dev/null @@ -1,218 +0,0 @@ -exports.BattleTextAFD = { - default: { - // April Fool's 2019 - // April Fool's 2025: tone down the most common text - // (I think the jokes work best as a surprise, when most of the - // text is the same as before.) - - // startBattle: "[TRAINER] and [TRAINER] commence battle!", - // winBattle: "**[TRAINER]** is victorious!", - // tieBattle: "The feud between [TRAINER] and [TRAINER] remains unresolved!", - - // pokemon: "[NICKNAME]", - // opposingPokemon: "a villainous [NICKNAME]", - // team: "your faithful alliance", - // opposingTeam: "the forces of evil", - // party: "our blessed allies", - // opposingParty: "the barbarous opposition", - - // turn: "== Phase [NUMBER] ==", - // switchIn: "[TRAINER] would have us contend with [FULLNAME]!", - // switchInOwn: "[FULLNAME]! Honor demands your presence!", - // switchOut: "[TRAINER] has other plans for [NICKNAME]!", - // switchOutOwn: "[NICKNAME], the time for retreat is upon us!", - // drag: "[FULLNAME] was unprepared to join us!", - // faint: "[POKEMON]'s service has concluded.", - // swap: "[POKEMON] and [TARGET] switched places!", - // swapCenter: "[POKEMON] moved to the center!", - - // zEffect: " [POKEMON] isn't holding back anymore!", - // move: "[POKEMON] unleashes **[MOVE]**!", - // abilityActivation: " [[POKEMON]'s [ABILITY]]", - - mega: " [POKEMON]'s [ITEM] glows!", - megaNoItem: " [POKEMON]'s lack of Mega Stone glows!", - megaGen6: " [POKEMON]'s [ITEM] glows!", - transformMega: "[POKEMON] thinks it's a big deal!", - primal: "[POKEMON]'s reversion! It got nostalgic about the old days!", - zPower: " [POKEMON] is about to stop holding back!", - zBroken: " [POKEMON]'s shields are failing!", - terastallize: " [POKEMON] is cosplaying as [TYPE]-type!", - - // in case the different default messages didn't make it obvious, the difference - // is that the `cant` message REPLACES "Pokemon used Move!", while the `fail` - // message happens AFTER "Pokemon used Move!" - cant: "[POKEMON] can't use [MOVE]!", - cantNoMove: "[POKEMON] can't move!", - fail: " Things did not go as planned!", - - // n.b. this is the default message for in-battle forme changes - // for the move Transform and ability Imposter, see the entry for the move Transform - transform: "[POKEMON] transformed!", - typeChange: " [POKEMON] transformed into the [TYPE] type!", - typeChangeFromEffect: " [POKEMON]'s [EFFECT] made it the [TYPE] type!", - typeAdd: " [TYPE] type was added to [POKEMON]!", - - start: " ([EFFECT] started on [POKEMON]!)", - end: " [POKEMON] was freed from [EFFECT]!", - activate: " ([EFFECT] activated!)", - startTeamEffect: " ([EFFECT] started on [TEAM]!)", - endTeamEffect: " ([EFFECT] ended on [TEAM]!)", - startFieldEffect: " ([EFFECT] started!)", - endFieldEffect: " ([EFFECT] ended!)", - - changeAbility: " [POKEMON] acquired [ABILITY]!", - addItem: " [POKEMON] obtained one [ITEM].", // Trick, Switcheroo - takeItem: " [POKEMON] stole [SOURCE]'s [ITEM]!", // Thief, Covet, Magician, Pickpocket - eatItem: " [POKEMON] ate its [ITEM]!", - useGem: " The [ITEM] strengthened [POKEMON]'s power!", - eatItemWeaken: " The [ITEM] weakened damage to [POKEMON]!", - removeItem: " [POKEMON] lost its [ITEM]!", - activateItem: " ([POKEMON] used its [ITEM]!)", - activateWeaken: " The [ITEM] weakened the damage to [POKEMON]!", - - damage: " ([POKEMON] was hurt!)", - damagePercentage: " ([POKEMON] lost [PERCENTAGE] of its health!)", - damageFromPokemon: " [POKEMON] is hurt by [SOURCE]'s [ITEM]!", // Jaboca/Rowap Berry - damageFromItem: " [POKEMON] is hurt by its [ITEM]!", // Sticky Barb - damageFromPartialTrapping: " [POKEMON] is hurt by [MOVE]!", - heal: " [POKEMON] restored its HP.", - healFromZEffect: " [POKEMON] restored its HP using its Z-Power!", - healFromEffect: " [POKEMON] restored HP using its [EFFECT]!", - - boost: " [POKEMON]'s [STAT] increased!", - boost2: " [POKEMON]'s [STAT] increased twice!", - boost3: " [POKEMON]'s [STAT] increased a lot!", - boost0: " [POKEMON]'s [STAT] won't go any higher!", - boostFromItem: " The [ITEM] raised [POKEMON]'s [STAT]!", - boost2FromItem: " The [ITEM] raised [POKEMON]'s [STAT] twice!", - boost3FromItem: " The [ITEM] raised [POKEMON]'s [STAT] a lot!", - boostFromZEffect: " [POKEMON] boosted its [STAT] using its Z-Power!", - boost2FromZEffect: " [POKEMON] boosted its [STAT] twice using its Z-Power!", - boost3FromZEffect: " [POKEMON] boosted its [STAT] a lot using its Z-Power!", - boostMultipleFromZEffect: " [POKEMON] boosted its stats using its Z-Power!", - - unboost: " [POKEMON]'s [STAT] was lowered!", - unboost2: " [POKEMON]'s [STAT] was lowered twice!", - unboost3: " [POKEMON]'s [STAT] was lowered a lot!", - unboost0: " [POKEMON]'s [STAT] won't go any lower!", - unboostFromItem: " The [ITEM] lowered [POKEMON]'s [STAT]!", - unboost2FromItem: " The [ITEM] lowered [POKEMON]'s [STAT] twice!", - unboost3FromItem: " The [ITEM] lowered [POKEMON]'s [STAT] a lot!", - - swapBoost: " [POKEMON] switched stat changes with its target!", - swapOffensiveBoost: " [POKEMON] switched all changes to its Strength and Intelligence with its target!", - swapDefensiveBoost: " [POKEMON] switched all changes to its Armor and Resistance with its target!", - copyBoost: " [POKEMON] copied [TARGET]'s stat changes!", - clearBoost: " [POKEMON]'s stat changes were removed!", - clearBoostFromZEffect: " [POKEMON] returned its decreased stats to normal using its Z-Power!", - invertBoost: " [POKEMON]'s stat changes were inverted!", - clearAllBoost: " All stat changes were eliminated!", - - superEffective: " It hit its enemy's weakness!", - superEffectiveSpread: " It hit [POKEMON]'s weakness!", - resisted: " It was resisted...", - resistedSpread: " [POKEMON] resisted the attack.", - crit: " A lucky hit! Remember to buy crit insurance!", - critSpread: " A lucky hit on [POKEMON]! How dare you!", - immune: " [POKEMON] is immune to such dastardly tricks!", - immuneNoPokemon: " The foe was immune!", // old gens - immuneOHKO: " [POKEMON] is unaffected!", - miss: " [POKEMON] avoided the attack!", - missNoPokemon: " [SOURCE]'s attack missed!", // old gens - - center: " Automatic center!", - noTarget: " But there was no target...", // gen 5 and earlier - ohko: " It's a one-hit KO!", - combine: " The two moves have become one! It's a combined move!", - hitCount: " Hit [NUMBER] times!", - hitCountSingular: " Hit 1 time!", - }, - dynamax: { - // April Fool's 2020 - start: " ([POKEMON]'s Tinymax!)", - end: " ([POKEMON] returned to normal!)", - block: " The move was blocked by the power of Tinymax!", - fail: " [POKEMON] shook its head. It seems like it can't use this move...", - }, - - // stats - hp: { - statName: "Constitution", - statShortName: "HP", - }, - atk: { - statName: "Strength", - statShortName: "Atk", - }, - def: { - statName: "Armor", - statShortName: "Def", - }, - spa: { - statName: "Intelligence", - statShortName: "SpA", - }, - spd: { - statName: "Resistance", - statShortName: "SpD", - }, - spe: { - statName: "Agility", - statShortName: "Spe", - }, - accuracy: { - statName: "accuracy", - }, - evasion: { - statName: "evasiveness", - }, - spc: { - statName: "Intelligence", - statShortName: "Spc", - }, - stats: { - statName: "stats", - }, - - // misc effects - confusion: { - start: " [POKEMON] became confused!", - startFromFatigue: " [POKEMON] became confused due to fatigue!", - end: " [POKEMON] snapped out of its confusion!", - endFromItem: " [POKEMON]'s [ITEM] snapped it out of its confusion!", - alreadyStarted: " [POKEMON] is already confused!", - activate: " [POKEMON] is confused!", - damage: "It hurt itself in its confusion!", - }, - drain: { - heal: " [SOURCE] will find its attacker's health restored!", - }, - flinch: { - cant: "[POKEMON] flinched and couldn't move!", - }, - healreplacement: { - activate: " [POKEMON] will restore its replacement's HP using its Z-Power!", - }, - nopp: { - cant: "[POKEMON] used [MOVE]!\n But there was no PP left for the move!", - }, - recharge: { - cant: "[POKEMON] must recharge!", - }, - recoil: { - damage: " [POKEMON] is damaged by the recoil!", - }, - unboost: { - fail: " [POKEMON]'s stats were not lowered!", - failSingular: " [POKEMON]'s [STAT] was not lowered!", - }, - struggle: { - activate: " [POKEMON] has no moves left!", - }, - trapped: { - start: " [POKEMON] can no longer escape!", - }, -}; - -exports.BattleTextNotAFD = exports.BattleText; diff --git a/play.pokemonshowdown.com/index-new.html b/play.pokemonshowdown.com/index-new.html index 4dc1c789a..3f496cd93 100644 --- a/play.pokemonshowdown.com/index-new.html +++ b/play.pokemonshowdown.com/index-new.html @@ -115,8 +115,7 @@ https://psim.us/dev - - + diff --git a/play.pokemonshowdown.com/src/battle-dex.ts b/play.pokemonshowdown.com/src/battle-dex.ts index 3ef61f006..00d022afc 100644 --- a/play.pokemonshowdown.com/src/battle-dex.ts +++ b/play.pokemonshowdown.com/src/battle-dex.ts @@ -86,6 +86,45 @@ export function toUserid(text: any) { return toID(text); } +const TEXT_LANGUAGES: { [language: string]: string } = { + english: 'en', german: 'de', spanish: 'es', french: 'fr', italian: 'it', + japanese: 'ja', korean: 'ko', simplifiedchinese: 'zh-cn', traditionalchinese: 'zh-tw', +}; + +interface ClientDexText { + getLanguage(): string; + get(effect: Species | Item | Ability | Move, lang?: string): BattleTextEntry; +} + +function getTextLanguage() { + const preference = Dex.prefs('language') || Dex.prefs('serversettings')?.language || 'english'; + const language = TEXT_LANGUAGES[preference] || 'en'; + return language === 'en' && Dex.afdMode === true ? 'en-afd' : language; +} + +function getTextEntry(effect: Species | Item | Ability | Move, gen: number, lang: string): BattleTextEntry { + const tableName = `${effect.effectType === 'Species' ? 'Pokedex' : `${effect.effectType}s`}` as keyof BattleTextData; + const english = BattleText.en?.[tableName]?.[effect.id] || {}; + const localized = BattleText[lang]?.[tableName]?.[effect.id] || {}; + const entry: BattleTextEntry = { ...english, ...localized }; + for (let i = 1; i <= 8; i++) { + const genName = `gen${i}`; + const englishGen = english[genName]; + const localizedGen = localized[genName]; + if (typeof englishGen === 'object' || typeof localizedGen === 'object') { + entry[genName] = { + ...(typeof englishGen === 'object' ? englishGen : {}), + ...(typeof localizedGen === 'object' ? localizedGen : {}), + }; + } + } + for (let i = 8; i >= gen; i--) { + const genName = `gen${i}`; + Object.assign(entry, english[genName] || {}, localized[genName] || {}); + } + return entry; +} + type Comparable = number | string | boolean | Comparable[] | { reverse: Comparable }; export const PSUtils = new class { /** @@ -254,6 +293,7 @@ export const Dex = new class implements ModdedDex { })(); loadedSpriteData = { xy: 1, bw: 0 }; + loadedTextData: { [lang: string]: 1 | Promise } = { en: 1 }; moddedDexes: { [mod: string]: ModdedDex } = {}; /** @@ -343,6 +383,13 @@ export const Dex = new class implements ModdedDex { return window.Storage?.prefs ? window.Storage.prefs(prop) : window.PS?.prefs?.[prop]; } + text: ClientDexText = { + getLanguage: getTextLanguage, + get: (effect: Species | Item | Ability | Move, lang = getTextLanguage()) => { + return getTextEntry(effect, 9, lang); + }, + }; + getShortName(name: string) { let shortName = name.replace(/[^A-Za-z0-9]+$/, ''); if (shortName.includes('(')) { @@ -583,6 +630,29 @@ export const Dex = new class implements ModdedDex { el.src = path + 'data/pokedex-mini-bw.js' + qs; document.getElementsByTagName('body')[0].appendChild(el); } + loadTextData(lang = this.text.getLanguage()): Promise { + lang = TEXT_LANGUAGES[lang] || lang; + if (BattleText[lang] || typeof document === 'undefined') return Promise.resolve(); + const existing = this.loadedTextData[lang]; + if (existing) return existing === 1 ? Promise.resolve() : existing; + + const loadScript = (src: string) => new Promise((resolve, reject) => { + const el = document.createElement('script'); + el.src = src; + el.onload = () => resolve(); + el.onerror = () => reject(new Error(`Failed to load text data from ${src}`)); + document.getElementsByTagName('body')[0].appendChild(el); + }); + let loading = loadScript(Config.testclient ? `data/text/${lang}.js` : `${this.resourcePrefix}data/text/${lang}.js`); + if (Config.testclient) { + loading = loading.catch(() => loadScript(`https://play.pokemonshowdown.com/data/text/${lang}.js`)); + } + loading = loading.catch(() => { + delete this.loadedTextData[lang]; + }); + this.loadedTextData[lang] = loading; + return loading; + } getSpriteData(pokemon: Pokemon | Species | string, isFront: boolean, options: { gen?: number, shiny?: boolean, @@ -980,6 +1050,12 @@ export class ModdedDex { if ((modid !== 'champions' && !modid.startsWith('gen')) || !gen) throw new Error("Unsupported modid"); this.gen = gen; } + text: ClientDexText = { + getLanguage: getTextLanguage, + get: (effect: Species | Item | Ability | Move, lang = getTextLanguage()) => { + return getTextEntry(effect, this.gen, lang); + }, + }; moves = { get: (name: string): Move => { let id = toID(name); diff --git a/play.pokemonshowdown.com/src/battle-log.ts b/play.pokemonshowdown.com/src/battle-log.ts index 79e7e356f..ffaddcde8 100644 --- a/play.pokemonshowdown.com/src/battle-log.ts +++ b/play.pokemonshowdown.com/src/battle-log.ts @@ -105,6 +105,10 @@ export class BattleLog { this.atBottom = (distanceFromBottom < 30); }; reset() { + if (this.battleParser) { + this.battleParser.language = Dex.text.getLanguage(); + this.battleParser.lowercaseRegExp = undefined; + } this.innerElem.innerHTML = ''; if (this.preemptElem) this.preemptElem.innerHTML = ''; this.atBottom = true; diff --git a/play.pokemonshowdown.com/src/battle-text-parser.ts b/play.pokemonshowdown.com/src/battle-text-parser.ts index cd534426d..4da7d4d96 100644 --- a/play.pokemonshowdown.com/src/battle-text-parser.ts +++ b/play.pokemonshowdown.com/src/battle-text-parser.ts @@ -13,24 +13,30 @@ import { Dex, toID, type ID } from "./battle-dex"; export type Args = [string, ...string[]]; export type KWArgs = { [kw: string]: string }; export type SideID = 'p1' | 'p2' | 'p3' | 'p4'; +export type InflectionCategories = { [placeholder: string]: string }; +type RenderValue = string | { + value: string, + table?: 'Items' | 'Default', + id?: string, + category?: string, +}; export class BattleTextParser { - /** escaped for string.replace */ p1 = "Player 1"; - /** escaped for string.replace */ p2 = "Player 2"; - /** escaped for string.replace */ p3 = "Player 3"; - /** escaped for string.replace */ p4 = "Player 4"; perspective: SideID; + language: string; gen = 9; turn = 0; curLineSection: 'break' | 'preMajor' | 'major' | 'postMajor' = 'break'; lowercaseRegExp: RegExp | null | undefined = undefined; - constructor(perspective: SideID = 'p1') { + constructor(perspective: SideID = 'p1', language = Dex.text.getLanguage()) { this.perspective = perspective; + this.language = language; + Dex.loadTextData(language); } static parseLine(line: string, noDefault: true): Args | null; @@ -233,10 +239,77 @@ export class BattleTextParser { return out; } + private textField(table: keyof BattleTextData, id: string, field: string) { + const english = BattleText.en?.[table]?.[id]; + const localized = BattleText[this.language]?.[table]?.[id]; + let value = localized?.[field] || english?.[field]; + for (let i = Dex.gen - 1; i >= this.gen; i--) { + const genName = `gen${i}`; + const englishGen = english?.[genName]; + const localizedGen = localized?.[genName]; + if (typeof localizedGen === 'object' && localizedGen[field]) value = localizedGen[field]; + else if (typeof englishGen === 'object' && englishGen[field]) value = englishGen[field]; + } + return typeof value === 'string' ? value : ''; + } + + private defaultText(field: string) { + return this.textField('Default', 'default', field); + } + + /** + * Render template, resolving placeholders. Array means a different value for each placeholder. + */ + private render( + template: string, values: { [placeholder: string]: RenderValue | RenderValue[] | undefined } = {} + ) { + const categories: InflectionCategories = {}; + const indexes: { [placeholder: string]: number } = {}; + const text = template.replace( + /\[([A-Z][A-Z0-9]*)(?::([a-z]+(?::[a-z]+)*))?\]/g, + (match, placeholder: string, modifierText: string | undefined) => { + const source = values[placeholder]; + if (source === undefined) return match; + let value: RenderValue | undefined; + if (Array.isArray(source)) { + const index = indexes[placeholder] || 0; + value = source[index]; + indexes[placeholder] = index + 1; + } else { + value = source; + } + if (value === undefined) return match; + return this.resolveRenderValue( + placeholder, value, modifierText ? modifierText.split(':') : [], categories + ); + } + ); + return BattleTextParser.inflect(text, categories); + } + + private resolveRenderValue( + placeholder: string, source: RenderValue, modifiers: string[], categories: InflectionCategories + ) { + let value = typeof source === 'string' ? source : source.value; + let category = typeof source === 'string' ? 'ms' : source.category || 'ms'; + let articleRule = ''; + if (typeof source !== 'string' && source.table && source.id) { + const entry = BattleText[this.language]?.[source.table]?.[source.id] || + BattleText.en?.[source.table]?.[source.id]; + let form = entry; + if (modifiers.includes('classified') && typeof entry?.classified === 'object') form = entry.classified; + value = typeof form?.name === 'string' ? form.name : value; + category = typeof form?.grammar === 'string' ? form.grammar : category; + articleRule = typeof form?.articleRule === 'string' ? form.articleRule : ''; + } + categories[placeholder] = category; + return BattleTextParser.modify(value, modifiers, this.language, category, articleRule); + } + fixLowercase(input: string) { if (this.lowercaseRegExp === undefined) { const prefixes = ['pokemon', 'opposingPokemon', 'team', 'opposingTeam', 'party', 'opposingParty'].map(templateId => { - const template = BattleText.default[templateId]; + const template = this.defaultText(templateId); if (template.startsWith(template.charAt(0).toUpperCase())) return ''; const bracketIndex = template.indexOf('['); if (bracketIndex >= 0) return template.slice(0, bracketIndex); @@ -264,33 +337,202 @@ export class BattleTextParser { return input.replace(/\$/g, '$$$$'); } - /** Returns a pokemon name escaped for passing into the second argument of string.replace */ + static inflect(template: string, categories: InflectionCategories) { + return template.replace( + /\[INFLECT:([A-Z][A-Z0-9]*):((?:\\.|[^\]\\])*)\]/g, + (match, placeholder: string, source: string) => { + const category = categories[placeholder]; + if (!category) return match; + const grammarCategory = /^[mfn][sup]$/.test(category); + const normalizedCategory = grammarCategory && category.endsWith('u') ? category.charAt(0) + 's' : category; + const categoryFallback = grammarCategory ? (category.endsWith('p') ? 'p' : 's') : ''; + + const fields: string[] = []; + let field = ''; + for (let i = 0; i < source.length; i++) { + if (source.charAt(i) === '\\' && i + 1 < source.length) { + field += source.charAt(i) + source.charAt(++i); + } else if (source.charAt(i) === ':') { + fields.push(field); + field = ''; + } else { + field += source.charAt(i); + } + } + fields.push(field); + + for (const candidate of fields) { + let equalsIndex = -1; + for (let i = 0; i < candidate.length; i++) { + if (candidate.charAt(i) === '\\') { + i++; + } else if (candidate.charAt(i) === '=') { + equalsIndex = i; + break; + } + } + if (equalsIndex < 0 || ![normalizedCategory, categoryFallback].includes(candidate.slice(0, equalsIndex))) continue; + return candidate.slice(equalsIndex + 1).replace(/\\(.)/g, '$1'); + } + return match; + } + ); + } + + static modify(value: string, modifiers: string[], language: string, category = 'ms', articleRule = '') { + const knownModifiers = [ + 'definite', 'indefinite', 'nominative', 'accusative', 'singular', 'plural', 'masculine', + 'capitalize', 'classified', 'a', 'de', 'di', 'su', 'e', 'y', + 'topic', 'object', 'subject', 'conjunctive', 'directional', + ]; + for (const modifier of modifiers) { + if (!knownModifiers.includes(modifier)) return value; + } + + const has = (modifier: string) => modifiers.includes(modifier); + // Names without localized grammar metadata default to masculine singular + let plural = category.endsWith('p'); + const uncountable = category.endsWith('u'); + let feminine = category.startsWith('f'); + let neuter = category.startsWith('n'); + if (has('singular')) plural = false; + if (has('plural')) plural = true; + if (has('masculine')) feminine = neuter = false; + const initial = this.grammarInitial(value); + const vowel = /^[aeiouà-æè-ïò-öù-ü]/i.test(initial); + let prefix = ''; + + if (language === 'fr') { + let article = ''; + if (has('definite')) { + article = plural ? 'les ' : vowel ? 'l’' : feminine ? 'la ' : 'le '; + } else if (has('indefinite')) { + article = uncountable ? '' : plural ? 'des ' : feminine ? 'une ' : 'un '; + } + if (has('a')) { + if (article === 'le ') prefix = 'au '; + else if (article === 'les ') prefix = 'aux '; + else prefix = 'à ' + article; + } else if (has('de')) { + if (article === 'le ') prefix = 'du '; + else if (article === 'les ') prefix = 'des '; + else if (article) prefix = 'de ' + article; + else prefix = vowel || /^h/i.test(initial) ? 'd’' : 'de '; + } else { + prefix = article; + } + } else if (language === 'es') { + const articleFeminine = feminine && articleRule !== 'stressed-a'; + let article = ''; + if (has('definite')) article = plural ? (feminine ? 'las ' : 'los ') : (articleFeminine ? 'la ' : 'el '); + else if (has('indefinite')) { + article = uncountable ? '' : plural ? (feminine ? 'unas ' : 'unos ') : (articleFeminine ? 'una ' : 'un '); + } + if (has('a')) prefix = article === 'el ' ? 'al ' : 'a ' + article; + else if (has('de')) prefix = article === 'el ' ? 'del ' : 'de ' + article; + else prefix = article; + if (has('y')) prefix = /^(?:i|hi)(?![aeou])/i.test(initial) ? 'e ' : 'y '; + } else if (language === 'it') { + const special = /^(?:s[^aeiouàèéìòù]|z|gn|ps|pn|x|y)/i.test(initial); + let article = ''; + if (has('definite')) { + if (plural) article = feminine ? 'le ' : (vowel || special ? 'gli ' : 'i '); + else if (vowel) article = 'l’'; + else article = feminine ? 'la ' : (special ? 'lo ' : 'il '); + } else if (has('indefinite')) { + if (uncountable) article = feminine ? (vowel ? 'dell’' : 'della ') : (vowel ? 'dell’' : special ? 'dello ' : 'del '); + else if (feminine) article = vowel ? 'un’' : 'una '; + else article = special ? 'uno ' : 'un '; + } + if (has('a') && has('definite')) { + prefix = this.italianContraction(article, ['al ', 'allo ', 'all’', 'alla ', 'ai ', 'agli ', 'alle ']); + } else if (has('di') && has('definite')) { + prefix = this.italianContraction(article, ['del ', 'dello ', 'dell’', 'della ', 'dei ', 'degli ', 'delle ']); + } else if (has('su') && has('definite')) { + prefix = this.italianContraction(article, ['sul ', 'sullo ', 'sull’', 'sulla ', 'sui ', 'sugli ', 'sulle ']); + } else if (has('a')) { + prefix = vowel ? 'ad ' : 'a '; + } else if (has('di')) { + prefix = vowel ? 'd’' : 'di '; + } else if (has('su')) { + prefix = 'su '; + } else if (has('e')) { + prefix = vowel ? 'ed ' : 'e '; + } else { + prefix = article; + } + } else if (language === 'de') { + const accusative = has('accusative'); + if (has('definite')) { + prefix = plural ? 'die ' : feminine ? 'die ' : neuter ? 'das ' : accusative ? 'den ' : 'der '; + } else if (has('indefinite')) { + prefix = uncountable ? '' : feminine ? 'eine ' : neuter ? 'ein ' : accusative ? 'einen ' : 'ein '; + } + } else if (language === 'ko') { + const jong = this.koreanJongseong(value); + if (has('topic')) value += jong ? '은' : '는'; + else if (has('object')) value += jong ? '을' : '를'; + else if (has('subject')) value += jong ? '이' : '가'; + else if (has('conjunctive')) value += jong ? '과' : '와'; + else if (has('directional')) value += jong && jong !== 8 ? '으로' : '로'; + } + + value = prefix + value; + if (has('capitalize')) { + for (let i = 0; i < value.length; i++) { + const letter = value.charAt(i); + if (letter.toUpperCase() === letter.toLowerCase()) continue; + value = value.slice(0, i) + letter.toUpperCase() + value.slice(i + 1); + break; + } + } + return value; + } + + private static grammarInitial(value: string) { + return value.replace(/\*\*/g, '').replace(/^[^A-Za-zÀ-ɏ0-9ㄱ-힣]+/, ''); + } + + private static italianContraction(article: string, forms: string[]) { + const articles = ['il ', 'lo ', 'l’', 'la ', 'i ', 'gli ', 'le ']; + const index = articles.indexOf(article); + return index < 0 ? forms[0] : forms[index]; + } + + private static koreanJongseong(value: string) { + const text = value.replace(/\*\*/g, '').replace(/[^A-Za-z0-9가-힣]+$/g, ''); + if (!text) return 0; + const code = text.charCodeAt(text.length - 1); + if (code >= 0xAC00 && code <= 0xD7A3) return (code - 0xAC00) % 28; + if (code >= 0x30 && code <= 0x39) return [1, 0, 0, 8, 0, 0, 1, 8, 8, 0][code - 0x30]; + return /[lmnr]$/i.test(text) ? 8 : 0; + } + pokemonName = (pokemon: string) => { if (!pokemon) return ''; if (!pokemon.startsWith('p')) return `???pokemon:${pokemon}???`; - if (pokemon.charAt(3) === ':') return BattleTextParser.escapeReplace(pokemon.slice(4).trim()); - else if (pokemon.charAt(2) === ':') return BattleTextParser.escapeReplace(pokemon.slice(3).trim()); + if (pokemon.charAt(3) === ':') return pokemon.slice(4).trim(); + else if (pokemon.charAt(2) === ':') return pokemon.slice(3).trim(); return `???pokemon:${pokemon}???`; }; - /** Returns a string escaped for passing into the second argument of string.replace */ pokemon(pokemon: string) { if (!pokemon) return ''; let side = pokemon.slice(0, 2); if (!['p1', 'p2', 'p3', 'p4'].includes(side)) return `???pokemon:${pokemon}???`; const name = this.pokemonName(pokemon); const isNear = side === this.perspective || side === BattleTextParser.allyID(side as SideID); - const template = BattleText.default[isNear ? 'pokemon' : 'opposingPokemon']; - return template.replace('[NICKNAME]', name).replace(/\$/g, '$$$$'); + const template = this.defaultText(isNear ? 'pokemon' : 'opposingPokemon'); + return this.render(template, { NICKNAME: name }); } - /** Returns a string escaped for passing into the second argument of string.replace */ - pokemonFull(pokemon: string, details: string): [string, string] { + pokemonFull(pokemon: string, details: string): [side: string, fullName: string] { const nickname = this.pokemonName(pokemon); const species = details.split(',')[0]; - if (nickname === species) return [pokemon.slice(0, 2), `**${species}**`]; - return [pokemon.slice(0, 2), `${nickname} (**${species}**)`]; + const localizedSpecies = this.speciesName(species); + if (nickname === localizedSpecies) return [pokemon.slice(0, 2), `**${localizedSpecies}**`]; + return [pokemon.slice(0, 2), `${nickname} (**${localizedSpecies}**)`]; } trainer(side: string) { @@ -313,9 +555,9 @@ export class BattleTextParser { team(side: string, isFar = false) { side = side.slice(0, 2); if (side === this.perspective || side === BattleTextParser.allyID(side as SideID)) { - return !isFar ? BattleText.default.team : BattleText.default.opposingTeam; + return this.defaultText(!isFar ? 'team' : 'opposingTeam'); } - return isFar ? BattleText.default.team : BattleText.default.opposingTeam; + return this.defaultText(isFar ? 'team' : 'opposingTeam'); } own(side: string) { @@ -329,9 +571,9 @@ export class BattleTextParser { party(side: string) { side = side.slice(0, 2); if (side === this.perspective || side === BattleTextParser.allyID(side as SideID)) { - return BattleText.default.party; + return this.defaultText('party'); } - return BattleText.default.opposingParty; + return this.defaultText('opposingParty'); } static effectId(effect?: string) { @@ -346,42 +588,68 @@ export class BattleTextParser { effect(effect?: string) { if (!effect) return ''; - if (effect.startsWith('item:') || effect.startsWith('move:')) { - effect = effect.slice(5); - } else if (effect.startsWith('ability:')) { - effect = effect.slice(8); - } + if (effect.startsWith('item:')) return this.itemValue(effect.slice(5)); + if (effect.startsWith('move:')) return this.moveName(effect.slice(5)); + if (effect.startsWith('ability:')) return this.abilityName(effect.slice(8)); return effect.trim(); } + textName(table: keyof BattleTextData, name: string) { + name = name.trim(); + const id = toID(name); + const localized = BattleText[this.language]?.[table]?.[id]?.name; + const english = BattleText.en?.[table]?.[id]?.name; + const translated = localized || english; + return typeof translated === 'string' ? translated : name; + } + + moveName(name: string) { + return this.textName('Moves', name); + } + itemName(name: string) { + return this.textName('Items', name); + } + private itemValue(name: string): RenderValue { + return { value: this.itemName(name), table: 'Items', id: toID(name) }; + } + abilityName(name: string) { + return this.textName('Abilities', name); + } + speciesName(name: string) { + return this.textName('Pokedex', name); + } + template(type: string, ...namespaces: (string | undefined)[]) { for (const namespace of namespaces) { if (!namespace) continue; if (namespace === 'OWN') { - return BattleText.default[type + 'Own'] + '\n'; + return this.defaultText(type + 'Own') + '\n'; } if (namespace === 'NODEFAULT') { return ''; } let id = BattleTextParser.effectId(namespace); - if (BattleText[id] && type in BattleText[id]) { - if (BattleText[id][type].charAt(1) === '.') type = BattleText[id][type].slice(2); - if (BattleText[id][type].startsWith('#')) id = BattleText[id][type].slice(1) as ID; - if (!BattleText[id][type]) return ''; - let template = BattleText[id][type]; - for (let i = Dex.gen - 1; i >= this.gen; i--) { - let curTemplate = BattleText[id][`${type}Gen${i}`]; - if (curTemplate) template = curTemplate; + let tables: (keyof BattleTextData)[]; + if (namespace.startsWith('item:')) tables = ['Items']; + else if (namespace.startsWith('ability:')) tables = ['Abilities']; + else if (namespace.startsWith('move:')) tables = ['Moves']; + else tables = ['Items', 'Abilities', 'Moves', 'Default']; + for (const table of tables) { + let template = this.textField(table, id, type); + if (!template) continue; + if (template.charAt(1) === '.') { + type = template.slice(2); + template = this.textField(table, id, type); } - return template + '\n'; + if (template.startsWith('#')) { + id = template.slice(1) as ID; + template = this.textField(table, id, type); + } + return template ? template + '\n' : ''; } } - if (!BattleText.default[type]) return ''; - let template = BattleText.default[type]; - for (let i = Dex.gen - 1; i >= this.gen; i--) { - let curTemplate = BattleText.default[`${type}Gen${i}`]; - if (curTemplate) template = curTemplate; - } + const template = this.defaultText(type); + if (!template) return ''; return template + '\n'; } @@ -393,13 +661,27 @@ export class BattleTextParser { ability(name: string | undefined, holder: string) { if (!name) return ''; - return BattleText.default.abilityActivation.replace('[POKEMON]', this.pokemon(holder)).replace('[ABILITY]', this.effect(name)) + '\n'; + return this.render(this.defaultText('abilityActivation'), { + POKEMON: this.pokemon(holder), + ABILITY: this.abilityName(name), + }) + '\n'; } - static stat(stat: string) { - const entry = BattleText[stat || "stats"]; - if (!entry?.statName) return `???stat:${stat}???`; - return entry.statName; + static stat(stat: string, language = Dex.text.getLanguage()) { + const id = stat || 'stats'; + const name = BattleText[language]?.Default[id]?.statName || BattleText.en?.Default[id]?.statName; + return typeof name === 'string' ? name : `???stat:${stat}???`; + } + static statShortName(stat: string, language = Dex.text.getLanguage()) { + const name = BattleText[language]?.Default[stat]?.statShortName || BattleText.en?.Default[stat]?.statShortName; + return typeof name === 'string' ? name : `???stat:${stat}???`; + } + private statValue(stat: string): RenderValue { + const id = stat || 'stats'; + return { + value: BattleTextParser.stat(stat, this.language), table: 'Default', id, + category: stat ? 's' : 'p', + }; } lineSection(args: Args, kwArgs: KWArgs) { @@ -471,13 +753,13 @@ export class BattleTextParser { case 'player': { const [, side, name] = args; if (side === 'p1' && name) { - this.p1 = BattleTextParser.escapeReplace(name); + this.p1 = name; } else if (side === 'p2' && name) { - this.p2 = BattleTextParser.escapeReplace(name); + this.p2 = name; } else if (side === 'p3' && name) { - this.p3 = BattleTextParser.escapeReplace(name); + this.p3 = name; } else if (side === 'p4' && name) { - this.p4 = BattleTextParser.escapeReplace(name); + this.p4 = name; } return ''; } @@ -491,33 +773,33 @@ export class BattleTextParser { case 'turn': { const [, num] = args; this.turn = Number.parseInt(num, 10); - return this.template('turn').replace('[NUMBER]', num) + '\n'; + return this.render(this.template('turn'), { NUMBER: num }) + '\n'; } case 'start': { - return this.template('startBattle').replace('[TRAINER]', this.p1).replace('[TRAINER]', this.p2); + return this.render(this.template('startBattle'), { TRAINER: [this.p1, this.p2] }); } case 'win': case 'tie': { const [, name] = args; if (cmd === 'tie' || !name) { - return this.template('tieBattle').replace('[TRAINER]', this.p1).replace('[TRAINER]', this.p2); + return this.render(this.template('tieBattle'), { TRAINER: [this.p1, this.p2] }); } - return this.template('winBattle').replace('[TRAINER]', name); + return this.render(this.template('winBattle'), { TRAINER: name }); } case 'switch': { const [, pokemon, details] = args; const [side, fullname] = this.pokemonFull(pokemon, details); const template = this.template('switchIn', this.own(side)); - return template.replace('[TRAINER]', this.trainer(side)).replace('[FULLNAME]', fullname); + return this.render(template, { TRAINER: this.trainer(side), FULLNAME: fullname }); } case 'drag': { const [, pokemon, details] = args; const [side, fullname] = this.pokemonFull(pokemon, details); const template = this.template('drag'); - return template.replace('[TRAINER]', this.trainer(side)).replace('[FULLNAME]', fullname); + return this.render(template, { TRAINER: this.trainer(side), FULLNAME: fullname }); } case 'detailschange': case '-transform': case '-formechange': { @@ -556,40 +838,49 @@ export class BattleTextParser { } const template = this.template(templateName, id, kwArgs.msg ? '' : 'NODEFAULT'); const line1 = this.maybeAbility(kwArgs.from, kwArgs.of || pokemon); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[SPECIES]', newSpecies); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), + SPECIES: this.speciesName(newSpecies), + }); } case 'switchout': { const [, pokemon] = args; const side = pokemon.slice(0, 2); const template = this.template('switchOut', kwArgs.from, this.own(side)); - return template.replace('[TRAINER]', this.trainer(side)).replace('[NICKNAME]', this.pokemonName(pokemon)).replace('[POKEMON]', this.pokemon(pokemon)); + return this.render(template, { + TRAINER: this.trainer(side), + NICKNAME: this.pokemonName(pokemon), + POKEMON: this.pokemon(pokemon), + }); } case 'faint': { const [, pokemon] = args; const template = this.template('faint'); - return template.replace('[POKEMON]', this.pokemon(pokemon)); + return this.render(template, { POKEMON: this.pokemon(pokemon) }); } case 'swap': { const [, pokemon, target] = args; if (!target || !isNaN(Number(target))) { const template = this.template('swapCenter'); - return template.replace('[POKEMON]', this.pokemon(pokemon)); + return this.render(template, { POKEMON: this.pokemon(pokemon) }); } const template = this.template('swap'); - return template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[TARGET]', this.pokemon(target)); + return this.render(template, { POKEMON: this.pokemon(pokemon), TARGET: this.pokemon(target) }); } case 'move': { const [, pokemon, move] = args; let line1 = this.maybeAbility(kwArgs.from, kwArgs.of || pokemon); if (kwArgs.zeffect) { - line1 = this.template('zEffect').replace('[POKEMON]', this.pokemon(pokemon)); + line1 = this.render(this.template('zEffect'), { POKEMON: this.pokemon(pokemon) }); } const template = this.template('move', kwArgs.from); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[MOVE]', move); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), MOVE: this.moveName(move), + }); } case 'cant': { @@ -597,7 +888,9 @@ export class BattleTextParser { const template = this.template('cant', effect, 'NODEFAULT') || this.template(move ? 'cant' : 'cantNoMove'); const line1 = this.maybeAbility(effect, kwArgs.of || pokemon); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[MOVE]', move); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), MOVE: this.moveName(move), + }); } case '-candynamax': { @@ -609,7 +902,7 @@ export class BattleTextParser { } else { template = this.template('canDynamax', own); } - return template.replace('[TRAINER]', this.trainer(side)); + return this.render(template, { TRAINER: this.trainer(side) }); } case 'message': { @@ -623,27 +916,30 @@ export class BattleTextParser { let id = BattleTextParser.effectId(effect); if (id === 'typechange') { const template = this.template('typeChange', kwArgs.from); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[TYPE]', arg3) - .replace('[SOURCE]', this.pokemon(kwArgs.of)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), TYPE: arg3, SOURCE: this.pokemon(kwArgs.of), + }); } if (id === 'typeadd') { const template = this.template('typeAdd', kwArgs.from); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[TYPE]', arg3); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon), TYPE: arg3 }); } if (id.startsWith('stockpile')) { const num = id.slice(9); const template = this.template('start', 'stockpile'); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[NUMBER]', num); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon), NUMBER: num }); } if (id.startsWith('perish')) { const num = id.slice(6); const template = this.template('activate', 'perishsong'); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[NUMBER]', num); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon), NUMBER: num }); } if (id.startsWith('protosynthesis') || id.startsWith('quarkdrive')) { const stat = id.slice(-3); const template = this.template('start', id.slice(0, id.length - 3)); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[STAT]', BattleTextParser.stat(stat)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), STAT: this.statValue(stat), + }); } let templateId = 'start'; if (kwArgs.already) templateId = 'alreadyStarted'; @@ -656,8 +952,13 @@ export class BattleTextParser { templateId += 'FromItem'; } const template = this.template(templateId, kwArgs.from, effect); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[EFFECT]', this.effect(effect)) - .replace('[MOVE]', arg3).replace('[SOURCE]', this.pokemon(kwArgs.of)).replace('[ITEM]', this.effect(kwArgs.from)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), + EFFECT: this.effect(effect), + MOVE: this.moveName(arg3), + SOURCE: this.pokemon(kwArgs.of), + ITEM: this.effect(kwArgs.from), + }); } case '-end': { @@ -666,7 +967,7 @@ export class BattleTextParser { let id = BattleTextParser.effectId(effect); if (id === 'doomdesire' || id === 'futuresight') { const template = this.template('activate', effect); - return line1 + template.replace('[TARGET]', this.pokemon(pokemon)); + return line1 + this.render(template, { TARGET: this.pokemon(pokemon) }); } let templateId = 'end'; let template = ''; @@ -674,8 +975,12 @@ export class BattleTextParser { template = this.template('endFromItem', effect); } if (!template) template = this.template(templateId, effect); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[EFFECT]', this.effect(effect)) - .replace('[SOURCE]', this.pokemon(kwArgs.of)).replace('[ITEM]', this.effect(kwArgs.from)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), + EFFECT: this.effect(effect), + SOURCE: this.pokemon(kwArgs.of), + ITEM: this.effect(kwArgs.from), + }); } case '-ability': { @@ -690,18 +995,21 @@ export class BattleTextParser { if (kwArgs.from) { if (!oldAbility) line1 = this.maybeAbility(kwArgs.from, pokemon) + line1; const template = this.template('changeAbility', kwArgs.from); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[ABILITY]', this.effect(ability)) - .replace('[SOURCE]', this.pokemon(kwArgs.of)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), + ABILITY: this.abilityName(ability), + SOURCE: this.pokemon(kwArgs.of), + }); } const id = BattleTextParser.effectId(ability); if (id === 'unnerve') { const template = this.template('start', ability); - return line1 + template.replace('[TEAM]', this.team(pokemon.slice(0, 2), true)); + return line1 + this.render(template, { TEAM: this.team(pokemon.slice(0, 2), true) }); } let templateId = 'start'; if (id === 'anticipation' || id === 'sturdy') templateId = 'activate'; const template = this.template(templateId, ability, 'NODEFAULT'); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon) }); } case '-endability': { @@ -709,7 +1017,7 @@ export class BattleTextParser { if (ability) return this.ability(ability, pokemon); const line1 = this.maybeAbility(kwArgs.from, kwArgs.of || pokemon); const template = this.template('start', 'Gastro Acid'); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon) }); } case '-item': { @@ -722,21 +1030,29 @@ export class BattleTextParser { const line1 = this.maybeAbility(kwArgs.from, kwArgs.of || pokemon); if (['thief', 'covet', 'bestow', 'magician', 'pickpocket'].includes(id)) { const template = this.template('takeItem', kwArgs.from); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[ITEM]', this.effect(item)) - .replace('[SOURCE]', this.pokemon(target || kwArgs.of)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), + ITEM: this.itemValue(item), + SOURCE: this.pokemon(target || kwArgs.of), + }); } if (id === 'frisk') { const hasTarget = kwArgs.of && pokemon && kwArgs.of !== pokemon; const template = this.template(hasTarget ? 'activate' : 'activateNoTarget', "Frisk"); - return line1 + template.replace('[POKEMON]', this.pokemon(kwArgs.of)).replace('[ITEM]', this.effect(item)) - .replace('[TARGET]', this.pokemon(pokemon)); + return line1 + this.render(template, { + POKEMON: this.pokemon(kwArgs.of), + ITEM: this.itemValue(item), + TARGET: this.pokemon(pokemon), + }); } if (kwArgs.from) { const template = this.template('addItem', kwArgs.from); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[ITEM]', this.effect(item)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), ITEM: this.itemValue(item), + }); } const template = this.template('start', item, 'NODEFAULT'); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon) }); } case '-enditem': { @@ -744,30 +1060,46 @@ export class BattleTextParser { const line1 = this.maybeAbility(kwArgs.from, kwArgs.of || pokemon); if (kwArgs.eat) { const template = this.template('eatItem', kwArgs.from); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[ITEM]', this.effect(item)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), ITEM: this.itemValue(item), + }); } const id = BattleTextParser.effectId(kwArgs.from); if (id === 'gem') { const template = this.template('useGem', item); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[ITEM]', this.effect(item)) - .replace('[MOVE]', kwArgs.move); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), + ITEM: this.itemValue(item), + MOVE: this.moveName(kwArgs.move), + }); } if (id === 'stealeat') { const template = this.template('removeItem', "Bug Bite"); - return line1 + template.replace('[SOURCE]', this.pokemon(kwArgs.of)).replace('[ITEM]', this.effect(item)); + return line1 + this.render(template, { + SOURCE: this.pokemon(kwArgs.of), ITEM: this.itemValue(item), + }); } if (kwArgs.from) { const template = this.template('removeItem', kwArgs.from); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[ITEM]', this.effect(item)) - .replace('[SOURCE]', this.pokemon(kwArgs.of)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), + ITEM: this.itemValue(item), + SOURCE: this.pokemon(kwArgs.of), + }); } if (kwArgs.weaken) { const template = this.template('activateWeaken'); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[ITEM]', this.effect(item)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), ITEM: this.itemValue(item), + }); } let template = this.template('end', item, 'NODEFAULT'); - if (!template) template = this.template('activateItem').replace('[ITEM]', this.effect(item)); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[TARGET]', this.pokemon(kwArgs.of)); + if (!template) template = this.template('activateItem'); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), + ITEM: this.itemValue(item), + TARGET: this.pokemon(kwArgs.of), + }); } case '-status': { @@ -775,34 +1107,40 @@ export class BattleTextParser { const line1 = this.maybeAbility(kwArgs.from, kwArgs.of || pokemon); if (kwArgs.from?.startsWith('item:')) { const template = this.template('startFromItem', status); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[ITEM]', this.effect(kwArgs.from)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), ITEM: this.effect(kwArgs.from), + }); } if (BattleTextParser.effectId(kwArgs.from) === 'rest') { const template = this.template('startFromRest', status); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon) }); } const template = this.template('start', status); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon) }); } case '-curestatus': { const [, pokemon, status] = args; if (BattleTextParser.effectId(kwArgs.from) === 'naturalcure') { const template = this.template('activate', kwArgs.from); - return template.replace('[POKEMON]', this.pokemon(pokemon)); + return this.render(template, { POKEMON: this.pokemon(pokemon) }); } const line1 = this.maybeAbility(kwArgs.from, kwArgs.of || pokemon); if (kwArgs.from?.startsWith('item:')) { const template = this.template('endFromItem', status); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[ITEM]', this.effect(kwArgs.from)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), ITEM: this.effect(kwArgs.from), + }); } if (kwArgs.thaw) { const template = this.template('endFromMove', status); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[MOVE]', this.effect(kwArgs.from)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), MOVE: this.effect(kwArgs.from), + }); } let template = this.template('end', status, 'NODEFAULT'); - if (!template) template = this.template('end').replace('[EFFECT]', status); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)); + if (!template) template = this.template('end'); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon), EFFECT: status }); } case '-cureteam': { @@ -816,33 +1154,45 @@ export class BattleTextParser { let id = BattleTextParser.effectId(effect); if (id === 'instruct') { const template = this.template('activate', effect); - return line1 + template.replace('[POKEMON]', this.pokemon(kwArgs.of)).replace('[TARGET]', this.pokemon(pokemon)); + return line1 + this.render(template, { + POKEMON: this.pokemon(kwArgs.of), TARGET: this.pokemon(pokemon), + }); } let template = this.template('start', effect, 'NODEFAULT'); - if (!template) template = this.template('start').replace('[EFFECT]', this.effect(effect)); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[SOURCE]', this.pokemon(kwArgs.of)) - .replace('[TEAM]', this.team(pokemon.slice(0, 2))); + if (!template) template = this.template('start'); + return line1 + this.render(template, { + EFFECT: this.effect(effect), + POKEMON: this.pokemon(pokemon), + SOURCE: this.pokemon(kwArgs.of), + TEAM: this.team(pokemon.slice(0, 2)), + }); } case '-sidestart': { let [, side, effect] = args; let template = this.template('start', effect, 'NODEFAULT'); - if (!template) template = this.template('startTeamEffect').replace('[EFFECT]', this.effect(effect)); - return template.replace('[TEAM]', this.team(side)).replace('[PARTY]', this.party(side)); + if (!template) template = this.template('startTeamEffect'); + return this.render(template, { + EFFECT: this.effect(effect), TEAM: this.team(side), PARTY: this.party(side), + }); } case '-sideend': { let [, side, effect] = args; let template = this.template('end', effect, 'NODEFAULT'); - if (!template) template = this.template('endTeamEffect').replace('[EFFECT]', this.effect(effect)); - return template.replace('[TEAM]', this.team(side)).replace('[PARTY]', this.party(side)); + if (!template) template = this.template('endTeamEffect'); + return this.render(template, { + EFFECT: this.effect(effect), TEAM: this.team(side), PARTY: this.party(side), + }); } case '-weather': { const [, weather] = args; if (!weather || weather === 'none') { const template = this.template('end', kwArgs.from, 'NODEFAULT'); - if (!template) return this.template('endFieldEffect').replace('[EFFECT]', this.effect(weather)); + if (!template) { + return this.render(this.template('endFieldEffect'), { EFFECT: this.effect(weather) }); + } return template; } if (kwArgs.upkeep) { @@ -850,28 +1200,32 @@ export class BattleTextParser { } const line1 = this.maybeAbility(kwArgs.from, kwArgs.of); let template = this.template('start', weather, 'NODEFAULT'); - if (!template) template = this.template('startFieldEffect').replace('[EFFECT]', this.effect(weather)); - return line1 + template; + if (!template) template = this.template('startFieldEffect'); + return line1 + this.render(template, { EFFECT: this.effect(weather) }); } case '-fieldstart': case '-fieldactivate': { const [, effect] = args; const line1 = this.maybeAbility(kwArgs.from, kwArgs.of); if (BattleTextParser.effectId(kwArgs.from) === 'hadronengine') { - return line1 + this.template('start', 'hadronengine').replace('[POKEMON]', this.pokemon(kwArgs.of)); + return line1 + this.render(this.template('start', 'hadronengine'), { + POKEMON: this.pokemon(kwArgs.of), + }); } let templateId = cmd.slice(6); if (BattleTextParser.effectId(effect) === 'perishsong') templateId = 'start'; let template = this.template(templateId, effect, 'NODEFAULT'); - if (!template) template = this.template('startFieldEffect').replace('[EFFECT]', this.effect(effect)); - return line1 + template.replace('[POKEMON]', this.pokemon(kwArgs.of)); + if (!template) template = this.template('startFieldEffect'); + return line1 + this.render(template, { + EFFECT: this.effect(effect), POKEMON: this.pokemon(kwArgs.of), + }); } case '-fieldend': { let [, effect] = args; let template = this.template('end', effect, 'NODEFAULT'); - if (!template) template = this.template('endFieldEffect').replace('[EFFECT]', this.effect(effect)); - return template; + if (!template) template = this.template('endFieldEffect'); + return this.render(template, { EFFECT: this.effect(effect) }); } case '-sethp': { @@ -893,7 +1247,9 @@ export class BattleTextParser { let [, pokemon, effect, target] = args; let id = BattleTextParser.effectId(effect); if (id === 'celebrate') { - return this.template('activate', 'celebrate').replace('[TRAINER]', this.trainer(pokemon.slice(0, 2))); + return this.render(this.template('activate', 'celebrate'), { + TRAINER: this.trainer(pokemon.slice(0, 2)), + }); } if (!target && ['hyperdrill', 'hyperspacefury', 'hyperspacehole', 'phantomforce', 'shadowforce', 'feint'].includes(id)) { @@ -906,14 +1262,16 @@ export class BattleTextParser { if (id === 'lockon' || id === 'mindreader') { const template = this.template('start', effect); - return line1 + template.replace('[POKEMON]', this.pokemon(kwArgs.of)).replace('[SOURCE]', this.pokemon(pokemon)); + return line1 + this.render(template, { + POKEMON: this.pokemon(kwArgs.of), SOURCE: this.pokemon(pokemon), + }); } if ((id === 'mummy' || id === 'lingeringaroma') && kwArgs.ability) { line1 += this.ability(kwArgs.ability, target); line1 += this.ability(id === 'mummy' ? 'Mummy' : 'Lingering Aroma', target); const template = this.template('changeAbility', id); - return line1 + template.replace('[TARGET]', this.pokemon(target)); + return line1 + this.render(template, { TARGET: this.pokemon(target) }); } if (id === 'commander') { @@ -921,7 +1279,9 @@ export class BattleTextParser { // so this is for backwards compatibility if (target === pokemon) return line1; const template = this.template('activate', id); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace(/\[TARGET\]/g, this.pokemon(target)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), TARGET: this.pokemon(target), + }); } let templateId = 'activate'; @@ -938,30 +1298,33 @@ export class BattleTextParser { if (!template) { if (line1) return line1; // Abilities don't have a default template template = this.template('activate'); - return line1 + template.replace('[EFFECT]', this.effect(effect)); + return line1 + this.render(template, { EFFECT: this.effect(effect) }); } - if (id === 'brickbreak') { - template = template.replace('[TEAM]', this.team(target.slice(0, 2))); - } if (kwArgs.ability) { line1 += this.ability(kwArgs.ability, pokemon); } if (kwArgs.ability2) { line1 += this.ability(kwArgs.ability2, target); } - if (kwArgs.move || kwArgs.number || kwArgs.item || kwArgs.name) { - template = template.replace('[MOVE]', kwArgs.move).replace('[NUMBER]', kwArgs.number) - .replace('[ITEM]', kwArgs.item).replace('[NAME]', kwArgs.name); - } - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[TARGET]', this.pokemon(target)) - .replace('[SOURCE]', this.pokemon(kwArgs.of)); + return line1 + this.render(template, { + TEAM: id === 'brickbreak' ? this.team(target.slice(0, 2)) : undefined, + MOVE: kwArgs.move ? this.moveName(kwArgs.move) : undefined, + NUMBER: kwArgs.number, + ITEM: kwArgs.item ? this.itemValue(kwArgs.item) : undefined, + NAME: kwArgs.name, + POKEMON: this.pokemon(pokemon), + TARGET: this.pokemon(target), + SOURCE: this.pokemon(kwArgs.of), + }); } case '-prepare': { const [, pokemon, effect, target] = args; const template = this.template('prepare', effect); - return template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[TARGET]', this.pokemon(target)); + return this.render(template, { + POKEMON: this.pokemon(pokemon), TARGET: this.pokemon(target), + }); } case '-damage': { @@ -970,25 +1333,33 @@ export class BattleTextParser { const line1 = this.maybeAbility(kwArgs.from, kwArgs.of || pokemon); const id = BattleTextParser.effectId(kwArgs.from); if (template) { - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon) }); } if (!kwArgs.from) { template = this.template(percentage ? 'damagePercentage' : 'damage'); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[PERCENTAGE]', percentage); + percentage = percentage ? percentage.replace(/%$/, '') : ''; + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), PERCENTAGE: percentage, + }); } if (kwArgs.from.startsWith('item:')) { template = this.template(kwArgs.of ? 'damageFromPokemon' : 'damageFromItem'); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[ITEM]', this.effect(kwArgs.from)) - .replace('[SOURCE]', this.pokemon(kwArgs.of)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), + ITEM: this.effect(kwArgs.from), + SOURCE: this.pokemon(kwArgs.of), + }); } if (kwArgs.partiallytrapped || id === 'bind' || id === 'wrap') { template = this.template('damageFromPartialTrapping'); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[MOVE]', this.effect(kwArgs.from)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), MOVE: this.effect(kwArgs.from), + }); } template = this.template('damage'); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon) }); } case '-heal': { @@ -996,17 +1367,22 @@ export class BattleTextParser { let template = this.template('heal', kwArgs.from, 'NODEFAULT'); const line1 = this.maybeAbility(kwArgs.from, kwArgs.of || pokemon); if (template) { - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[SOURCE]', this.pokemon(kwArgs.of)) - .replace('[NICKNAME]', kwArgs.wisher); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), + SOURCE: this.pokemon(kwArgs.of), + NICKNAME: kwArgs.wisher, + }); } if (kwArgs.from && !kwArgs.from.startsWith('ability:')) { template = this.template('healFromEffect'); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[EFFECT]', this.effect(kwArgs.from)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), EFFECT: this.effect(kwArgs.from), + }); } template = this.template('heal'); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon) }); } case '-boost': case '-unboost': { @@ -1022,11 +1398,16 @@ export class BattleTextParser { templateId += (kwArgs.multiple ? 'MultipleFromZEffect' : 'FromZEffect'); } else if (amount && kwArgs.from?.startsWith('item:')) { const template = this.template(templateId + 'FromItem', kwArgs.from); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[STAT]', BattleTextParser.stat(stat)) - .replace('[ITEM]', this.effect(kwArgs.from)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), + STAT: this.statValue(stat), + ITEM: this.effect(kwArgs.from), + }); } const template = this.template(templateId, kwArgs.from); - return line1 + template.replace(/\[POKEMON\]/g, this.pokemon(pokemon)).replace('[STAT]', BattleTextParser.stat(stat)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), STAT: this.statValue(stat), + }); } case '-setboost': { @@ -1034,7 +1415,7 @@ export class BattleTextParser { const effect = kwArgs.from; const line1 = this.maybeAbility(effect, kwArgs.of || pokemon); const template = this.template('boost', effect); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon) }); } case '-swapboost': { @@ -1045,14 +1426,18 @@ export class BattleTextParser { if (id === 'guardswap') templateId = 'swapDefensiveBoost'; if (id === 'powerswap') templateId = 'swapOffensiveBoost'; const template = this.template(templateId, kwArgs.from); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[TARGET]', this.pokemon(target)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), TARGET: this.pokemon(target), + }); } case '-copyboost': { const [, pokemon, target] = args; const line1 = this.maybeAbility(kwArgs.from, kwArgs.of || pokemon); const template = this.template('copyBoost', kwArgs.from); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[TARGET]', this.pokemon(target)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), TARGET: this.pokemon(target), + }); } case '-clearboost': case '-clearpositiveboost': case '-clearnegativeboost': { @@ -1061,14 +1446,16 @@ export class BattleTextParser { let templateId = 'clearBoost'; if (kwArgs.zeffect) templateId = 'clearBoostFromZEffect'; const template = this.template(templateId, kwArgs.from); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[SOURCE]', this.pokemon(source)); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), SOURCE: this.pokemon(source), + }); } case '-invertboost': { const [, pokemon] = args; const line1 = this.maybeAbility(kwArgs.from, kwArgs.of || pokemon); const template = this.template('invertBoost', kwArgs.from); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon) }); } case '-clearallboost': { @@ -1085,15 +1472,18 @@ export class BattleTextParser { } if (kwArgs.spread) templateId += 'Spread'; const template = this.template(templateId); - return template.replace('[POKEMON]', this.pokemon(pokemon)); + return this.render(template, { POKEMON: this.pokemon(pokemon) }); } case '-block': { let [, pokemon, effect, move, attacker] = args; const line1 = this.maybeAbility(effect, kwArgs.of || pokemon); const template = this.template('block', effect); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)) - .replace('[SOURCE]', this.pokemon(attacker || kwArgs.of)).replace('[MOVE]', move); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), + SOURCE: this.pokemon(attacker || kwArgs.of), + MOVE: this.moveName(move), + }); } case '-fail': { @@ -1110,12 +1500,14 @@ export class BattleTextParser { } let template = this.template(templateId, kwArgs.from); if (template) { - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon) }); } if (id === 'unboost') { - template = this.template(stat ? 'failSingular' : 'fail', 'unboost'); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[STAT]', stat); + template = this.template('fail', 'unboost'); + return line1 + this.render(template, { + POKEMON: this.pokemon(pokemon), STAT: this.statValue(stat), + }); } templateId = 'fail'; @@ -1126,7 +1518,7 @@ export class BattleTextParser { if (kwArgs.weak) templateId = 'fail'; if (kwArgs.forme) templateId = 'failWrongForme'; template = this.template(templateId, id); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon) }); } case '-immune': { @@ -1137,7 +1529,7 @@ export class BattleTextParser { const templateId = kwArgs.ohko ? 'immuneOHKO' : 'immune'; template = this.template(pokemon ? templateId : 'immuneNoPokemon', kwArgs.from); } - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon) }); } case '-miss': { @@ -1145,10 +1537,10 @@ export class BattleTextParser { const line1 = this.maybeAbility(kwArgs.from, kwArgs.of || pokemon); if (!pokemon) { const template = this.template('missNoPokemon'); - return line1 + template.replace('[SOURCE]', this.pokemon(source)); + return line1 + this.render(template, { SOURCE: this.pokemon(source) }); } const template = this.template('miss'); - return line1 + template.replace('[POKEMON]', this.pokemon(pokemon)); + return line1 + this.render(template, { POKEMON: this.pokemon(pokemon) }); } case '-center': case '-ohko': case '-combine': { @@ -1172,10 +1564,14 @@ export class BattleTextParser { const side = pokemon.slice(0, 2); const pokemonName = this.pokemon(pokemon); if (cmd === '-mega') { - const template2 = this.template('transformMega'); - template += template2.replace('[POKEMON]', pokemonName).replace('[SPECIES]', species); + template += this.template('transformMega'); } - return template.replace('[POKEMON]', pokemonName).replace('[ITEM]', item).replace('[TRAINER]', this.trainer(side)); + return this.render(template, { + POKEMON: pokemonName, + SPECIES: this.speciesName(species), + ITEM: this.itemValue(item), + TRAINER: this.trainer(side), + }); } case '-terastallize': { @@ -1184,39 +1580,40 @@ export class BattleTextParser { let templateId = cmd.slice(1); let template = this.template(templateId, id); const pokemonName = this.pokemon(pokemon); - return template.replace('[POKEMON]', pokemonName).replace('[TYPE]', type); + return this.render(template, { POKEMON: pokemonName, TYPE: type }); } case '-zpower': { const [, pokemon] = args; const template = this.template('zPower'); - return template.replace('[POKEMON]', this.pokemon(pokemon)); + return this.render(template, { POKEMON: this.pokemon(pokemon) }); } case '-burst': { const [, pokemon] = args; const template = this.template('activate', "Ultranecrozium Z"); - return template.replace('[POKEMON]', this.pokemon(pokemon)); + return this.render(template, { POKEMON: this.pokemon(pokemon) }); } case '-zbroken': { const [, pokemon] = args; const template = this.template('zBroken'); - return template.replace('[POKEMON]', this.pokemon(pokemon)); + return this.render(template, { POKEMON: this.pokemon(pokemon) }); } case '-hitcount': { const [, , num] = args; - if (num === '1') { - return this.template('hitCountSingular'); - } - return this.template('hitCount').replace('[NUMBER]', num); + return this.render(this.template('hitCount'), { + NUMBER: { value: num, category: num === '1' ? 's' : 'p' }, + }); } case '-waiting': { const [, pokemon, target] = args; const template = this.template('activate', "Water Pledge"); - return template.replace('[POKEMON]', this.pokemon(pokemon)).replace('[TARGET]', this.pokemon(target)); + return this.render(template, { + POKEMON: this.pokemon(pokemon), TARGET: this.pokemon(target), + }); } case '-anim': { diff --git a/play.pokemonshowdown.com/src/battle-tooltips.ts b/play.pokemonshowdown.com/src/battle-tooltips.ts index d2d0ebebd..1149a902e 100644 --- a/play.pokemonshowdown.com/src/battle-tooltips.ts +++ b/play.pokemonshowdown.com/src/battle-tooltips.ts @@ -1519,7 +1519,7 @@ export class BattleTooltips { if (this.battle.gen === 1 && statName === 'spd') continue; let statLabel = this.battle.gen === 1 && statName === 'spa' ? 'spc' : statName; buf += statName === 'atk' ? '' : ' / '; - buf += `${BattleText[statLabel].statShortName} `; + buf += `${BattleTextParser.statShortName(statLabel)} `; buf += `${stats[statName]}`; if (modifiedStats[statName] !== stats[statName]) hasModifiedStat = true; } @@ -1535,7 +1535,7 @@ export class BattleTooltips { if (this.battle.gen === 1 && statName === 'spd') continue; let statLabel = this.battle.gen === 1 && statName === 'spa' ? 'spc' : statName; buf += statName === 'atk' ? '' : ' / '; - buf += `${BattleText[statLabel].statShortName} `; + buf += `${BattleTextParser.statShortName(statLabel)} `; if (modifiedStats[statName] === stats[statName]) { buf += `${modifiedStats[statName]}`; } else if (modifiedStats[statName] < stats[statName]) { diff --git a/play.pokemonshowdown.com/src/client-main.ts b/play.pokemonshowdown.com/src/client-main.ts index 80953c37b..91b83d6fb 100644 --- a/play.pokemonshowdown.com/src/client-main.ts +++ b/play.pokemonshowdown.com/src/client-main.ts @@ -20,9 +20,6 @@ import type { BattleRoom } from './panel-battle'; import { Teams } from './battle-teams'; import type preact from '../js/lib/preact'; -declare const BattleTextAFD: any; -declare const BattleTextNotAFD: any; - export const VERTICAL_HEADER_WIDTH = 240; export const NARROW_MODE_HEADER_WIDTH = 280; @@ -291,17 +288,6 @@ class PSPrefs extends PSStreamModel { setAFD(mode?: typeof this['afd']) { if (mode === undefined) { - // init - if (typeof BattleTextAFD !== 'undefined') { - for (const id in BattleTextNotAFD) { - if (!BattleTextAFD[id]) { - BattleTextAFD[id] = BattleTextNotAFD[id]; - } else { - BattleTextAFD[id] = { ...BattleTextNotAFD[id], ...BattleTextAFD[id] }; - } - } - } - if (Config.server?.afd) { mode = true; } else if (this.afd !== undefined) { @@ -313,14 +299,7 @@ class PSPrefs extends PSStreamModel { } Dex.afdMode = mode; - - if (typeof BattleTextAFD !== 'undefined') { - if (mode === true) { - (BattleText as any) = BattleTextAFD; - } else { - (BattleText as any) = BattleTextNotAFD; - } - } + if (mode === true) Dex.loadTextData('en-afd'); } setShowDebug(mode = this.showdebug) { const css = mode ? `.debug {display: block;}` : `.debug {display: none;}`; @@ -1551,11 +1530,12 @@ export class PSRoom extends PSStreamModel implements RoomOptions { this.add('||AFD is currently set to ' + curMode); this.send('/help afd'); } - for (let roomid in PS.rooms) { - let battle = PS.rooms[roomid] && (PS.rooms[roomid] as BattleRoom).battle; - if (!battle) continue; - battle.resetToCurrentTurn(); - } + void Dex.loadTextData().then(() => { + for (const roomid in PS.rooms) { + const battle = (PS.rooms[roomid] as BattleRoom)?.battle; + if (battle) battle.resetToCurrentTurn(); + } + }); }, 'clearpms'() { let rooms = PS.miniRoomList.filter(roomid => roomid.startsWith('dm-')); diff --git a/play.pokemonshowdown.com/src/globals.d.ts b/play.pokemonshowdown.com/src/globals.d.ts index 04ec3c619..142edad25 100644 --- a/play.pokemonshowdown.com/src/globals.d.ts +++ b/play.pokemonshowdown.com/src/globals.d.ts @@ -4,7 +4,16 @@ /////////// type AnyObject = { [k: string]: any }; -declare const BattleText: { [id: string]: { [templateName: string]: string } }; +type BattleTextEntry = { [templateName: string]: string | BattleTextEntry }; +type BattleTextTable = { [id: string]: BattleTextEntry }; +type BattleTextData = { + Default: BattleTextTable, + Pokedex: BattleTextTable, + Moves: BattleTextTable, + Abilities: BattleTextTable, + Items: BattleTextTable, +}; +declare const BattleText: { [lang: string]: BattleTextData }; declare const BattleFormats: { [id: string]: import('./panel-teamdropdown').FormatData }; declare const BattlePokedex: { [id: string]: AnyObject }; declare const BattleMovedex: { [id: string]: AnyObject }; diff --git a/play.pokemonshowdown.com/src/oldclient/client-chat.js b/play.pokemonshowdown.com/src/oldclient/client-chat.js index 5f41a60a7..39e254ba7 100644 --- a/play.pokemonshowdown.com/src/oldclient/client-chat.js +++ b/play.pokemonshowdown.com/src/oldclient/client-chat.js @@ -1174,11 +1174,12 @@ this.add('AFD is currently set to ' + mode); this.parseCommand('/help afd'); } - for (var roomid in app.rooms) { - var battle = app.rooms[roomid] && app.rooms[roomid].battle; - if (!battle) continue; - battle.resetToCurrentTurn(); - } + Dex.loadTextData().then(function () { + for (var roomid in app.rooms) { + var battle = app.rooms[roomid] && app.rooms[roomid].battle; + if (battle) battle.resetToCurrentTurn(); + } + }); return false; // documentation of client commands diff --git a/play.pokemonshowdown.com/src/oldclient/client-topbar.js b/play.pokemonshowdown.com/src/oldclient/client-topbar.js index 59756a92e..241dd89a6 100644 --- a/play.pokemonshowdown.com/src/oldclient/client-topbar.js +++ b/play.pokemonshowdown.com/src/oldclient/client-topbar.js @@ -550,8 +550,9 @@ "Türkçe": 'turkish', "हिंदी": 'hindi', "日本語": 'japanese', + "한국어": 'korean', "简体中文": 'simplifiedchinese', - "中文": 'traditionalchinese' + "繁體中文": 'traditionalchinese' }; buf += '

+

+
+

Appearance

-