diff --git a/play.pokemonshowdown.com/src/battle-dex.ts b/play.pokemonshowdown.com/src/battle-dex.ts index 637baf7d2..f63bef0e9 100644 --- a/play.pokemonshowdown.com/src/battle-dex.ts +++ b/play.pokemonshowdown.com/src/battle-dex.ts @@ -26,6 +26,7 @@ import { import type * as DexData from "./battle-dex-data"; import type { Teams } from "./battle-teams"; import { Config } from "./client-main"; +import { BattleLog } from "./battle-log"; export declare namespace Dex { /* eslint-disable @typescript-eslint/no-shadow */ @@ -100,15 +101,17 @@ const TEXT_TABLES = { Move: 'Moves', } as const; -interface ClientDexTextEntry extends BattleTextEntry { +interface ClientEffectTextEntry extends BattleTextEntry { name: string; desc: string; shortDesc: string; + baseSpecies: string; + forme: string | null; } interface ClientDexText { getLanguage(): string; - get(effect: TranslatableEffect, lang?: string): ClientDexTextEntry; + get(effect: TranslatableEffect, lang?: string): ClientEffectTextEntry; termName(name: string, lang?: string): string; typeName(name: string, lang?: string): string; natureName(name: string, lang?: string): string; @@ -208,22 +211,28 @@ function getOtherName(table: OtherNameTable, name: string, lang: string): string return BattleText[lang]?.[table]?.[id] || BattleText.en?.[table]?.[id] || name; } -function getTextEntry(effect: TranslatableEffect, gen: number, lang: string): ClientDexTextEntry { +/** + * Does actually match ClientEffectTextEntry exactly. + */ +function getTextEntry(effect: TranslatableEffect, gen: number, lang: string): ClientEffectTextEntry { if (effect.effectType === 'Species') { - const name = BattleText[lang]?.PokedexNames?.[effect.id] || - BattleText.en?.PokedexNames?.[effect.id] || effect.name; - return { name, desc: '', shortDesc: '' }; + const entry = BattleText[lang]?.Pokedex?.[effect.id] || BattleText.en?.Pokedex?.[effect.id]; + return { + name: entry?.name || effect.name, + baseSpecies: entry?.baseSpecies || effect.baseSpecies, + forme: entry?.forme || null, + } as ClientEffectTextEntry; } if (effect.effectType === 'Type') { - return { name: getOtherName('TypeNames', effect.name, lang), desc: '', shortDesc: '' }; + return { name: getOtherName('TypeNames', effect.name, lang) } as ClientEffectTextEntry; } if (effect.effectType === 'Nature') { - return { name: getOtherName('NatureNames', effect.name, lang), desc: '', shortDesc: '' }; + return { name: getOtherName('NatureNames', effect.name, lang) } as ClientEffectTextEntry; } const tableName = TEXT_TABLES[effect.effectType]; const english = BattleText.en?.[tableName]?.[effect.id] || {}; const localized = BattleText[lang]?.[tableName]?.[effect.id] || {}; - const entry = {} as ClientDexTextEntry; + const entry = {} as ClientEffectTextEntry; assignTextFields(entry, english); assignTextFields(entry, localized); for (let i = 1; i <= 8; i++) { diff --git a/play.pokemonshowdown.com/src/battle-searchresults.tsx b/play.pokemonshowdown.com/src/battle-searchresults.tsx index 421b34bd2..8d6ebb87e 100644 --- a/play.pokemonshowdown.com/src/battle-searchresults.tsx +++ b/play.pokemonshowdown.com/src/battle-searchresults.tsx @@ -101,10 +101,12 @@ export class PSSearchResults extends preact.Component<{ const pokemon = search.dex.species.get(id); if (!pokemon) return `
  • Unrecognized pokemon
  • `; - const tagStart = (pokemon.forme ? pokemon.name.length - pokemon.forme.length - 1 : 0); - const displayName = search.dex.text.get(pokemon).name; + const pokemonText = search.dex.text.get(pokemon); + const displayName = pokemonText.name; [matchStart, matchEnd] = getLocalizedMatch(displayName, pokemon.name, matchStart, matchEnd); - const displayTagStart = displayName === pokemon.name ? tagStart : 0; + const baseIndex = pokemonText.baseSpecies ? displayName.indexOf(pokemonText.baseSpecies) : -1; + const baseStart = Math.max(0, baseIndex); + const baseEnd = baseIndex < 0 ? 0 : baseIndex + pokemonText.baseSpecies.length; const stats = pokemon.baseStats; let bst = 0; for (const stat of Object.values(stats)) bst += stat; @@ -115,7 +117,9 @@ export class PSSearchResults extends preact.Component<{ `data-entry="pokemon|${escapeHTML(pokemon.name)}">` + `${escapeHTML(search.getTier(pokemon))}` + `` + - `${this.renderNameHTML(displayName, matchStart, matchEnd, displayTagStart)}`; + `${this.renderNameHTML( + displayName, matchStart, matchEnd, baseStart, baseEnd + )}`; if (errorMessage) return `${buf}${errorMessage}`; buf += `${pokemon.types.map(type => @@ -158,8 +162,21 @@ export class PSSearchResults extends preact.Component<{ return buf; } - renderNameHTML(name: string, matchStart: number, matchEnd: number, tagStart?: number) { + renderNameHTML(name: string, matchStart: number, matchEnd: number, tagStart?: number, tagEnd?: number) { if (name === 'No Ability') return `(no ability)`; + if (tagStart !== undefined && tagEnd !== undefined) { + const renderPart = (start: number, end: number) => { + if (!matchEnd || matchEnd <= start || matchStart >= end) return escapeHTML(name.slice(start, end)); + const boldStart = Math.max(start, matchStart); + const boldEnd = Math.min(end, matchEnd); + return escapeHTML(name.slice(start, boldStart)) + + `${escapeHTML(name.slice(boldStart, boldEnd))}` + + escapeHTML(name.slice(boldEnd, end)); + }; + return (tagStart ? `${renderPart(0, tagStart)}` : '') + + renderPart(tagStart, tagEnd) + + (tagEnd < name.length ? `${renderPart(tagEnd, name.length)}` : ''); + } if (!matchEnd) { if (!tagStart) return escapeHTML(name); diff --git a/play.pokemonshowdown.com/src/battle-text-parser.ts b/play.pokemonshowdown.com/src/battle-text-parser.ts index 5a0e11e42..57f3bfc0d 100644 --- a/play.pokemonshowdown.com/src/battle-text-parser.ts +++ b/play.pokemonshowdown.com/src/battle-text-parser.ts @@ -535,7 +535,12 @@ export class BattleTextParser { const species = details.split(',')[0]; const localizedSpecies = this.speciesName(species); if (nickname === localizedSpecies) return [pokemon.slice(0, 2), `**${localizedSpecies}**`]; - return [pokemon.slice(0, 2), `${nickname} (**${localizedSpecies}**)`]; + const template = BattleText[this.language]?.TermNames?.nicknamespecies || + BattleText.en?.TermNames?.nicknamespecies || '[NICKNAME] ([SPECIES])'; + return [pokemon.slice(0, 2), this.render(template, { + NICKNAME: nickname, + SPECIES: `**${localizedSpecies}**`, + })]; } trainer(side: string) { @@ -622,8 +627,7 @@ export class BattleTextParser { speciesName(name?: string) { if (!name) return ''; name = name.trim(); - const id = toID(name); - return BattleText[this.language]?.PokedexNames?.[id] || BattleText.en?.PokedexNames?.[id] || name; + return Dex.text.get(Dex.species.get(name), this.language).name || name; } template(type: string, ...namespaces: (string | undefined)[]) { @@ -676,8 +680,7 @@ export class BattleTextParser { static stat(stat: string, language = Dex.text.getLanguage()) { const id = stat || 'stats'; - const table = id === 'stats' ? 'TermNames' : 'StatNames'; - const name = BattleText[language]?.[table]?.[id] || BattleText.en?.[table]?.[id]; + const name = BattleText[language]?.StatNames?.[id] || BattleText.en?.StatNames?.[id]; return typeof name === 'string' ? name : `???stat:${stat}???`; } static statMediumName(stat: string, language = Dex.text.getLanguage()) { @@ -690,9 +693,8 @@ export class BattleTextParser { } private statValue(stat: string): RenderValue { const id = stat || 'stats'; - const table = id === 'stats' ? 'TermNames' : 'StatNames'; - const grammar = BattleText[this.language]?.[table]?.[`${id}:grammar`] || - BattleText.en?.[table]?.[`${id}:grammar`]; + const grammar = BattleText[this.language]?.StatNames?.[`${id}:grammar`] || + BattleText.en?.StatNames?.[`${id}:grammar`]; return { value: BattleTextParser.stat(stat, this.language), category: grammar || (stat ? 's' : 'p'), diff --git a/play.pokemonshowdown.com/src/globals.d.ts b/play.pokemonshowdown.com/src/globals.d.ts index 28f79e3be..bb0e98480 100644 --- a/play.pokemonshowdown.com/src/globals.d.ts +++ b/play.pokemonshowdown.com/src/globals.d.ts @@ -8,7 +8,7 @@ type BattleTextEntry = { [templateName: string]: string | null | BattleTextEntry type BattleTextTable = { [id: string]: BattleTextEntry }; type BattleTextData = { Default: BattleTextTable, - PokedexNames: { [id: string]: string }, + Pokedex: { [id: string]: { name: string, baseSpecies: string, forme?: string, grammar?: string } }, TermNames: { [id: string]: string }, TypeNames: { [id: string]: string }, NatureNames: { [id: string]: string }, diff --git a/test/battle-searchresults.test.js b/test/battle-searchresults.test.js new file mode 100644 index 000000000..383980874 --- /dev/null +++ b/test/battle-searchresults.test.js @@ -0,0 +1,55 @@ +const assert = require('assert').strict; +const fs = require('fs'); +const path = require('path'); +const {describe, it} = require('node:test'); +const vm = require('vm'); + +global.preact = {Component: function () {}}; +global.Config = {routes: {dex: 'dex.pokemonshowdown.com'}}; +global.Dex = {resourcePrefix: '', getPokemonIcon() { return ''; }}; + +const searchResultsPath = path.resolve(__dirname, '../play.pokemonshowdown.com/js/battle-searchresults.js'); +const PSSearchResults = vm.runInThisContext( + `${fs.readFileSync(searchResultsPath, 'utf8')}\nPSSearchResults;`, + {filename: searchResultsPath} +); + +describe('PSSearchResults', () => { + it('renders everything outside the localized base species in small text', () => { + const renderRow = (canonicalName, name, baseSpecies) => { + const pokemon = { + id: canonicalName.toLowerCase().replace(/[^a-z0-9]+/g, ''), + name: canonicalName, + baseStats: {hp: 1, atk: 1, def: 1, spa: 1, spd: 1, spe: 1}, + types: [], + abilities: {}, + }; + const search = { + dex: { + gen: 2, + species: {get() { return pokemon; }}, + text: {get() { return {name, baseSpecies}; }}, + }, + getTier() { return ''; }, + }; + const renderer = Object.create(PSSearchResults.prototype); + renderer.props = {search}; + renderer.URL_ROOT = '//dex.pokemonshowdown.com/'; + renderer.speciesId = ''; + return renderer.renderPokemonRowHTML(0, pokemon.id, 0, 0); + }; + + assert.match(renderRow('Thundurus', 'Thundurus-Incarnate', 'Thundurus'), + /Thundurus-Incarnate<\/small><\/span>/); + assert.match(renderRow('Thundurus-Therian', 'Thundurus-Therian', 'Thundurus'), + /Thundurus-Therian<\/small><\/span>/); + assert.match(renderRow('Mewtwo-Mega-X', 'Mega Mewtwo X', 'Mewtwo'), + /Mega <\/small>Mewtwo X<\/small><\/span>/); + assert.match(renderRow('Thundurus', 'Inkarnations-Voltolos', 'Voltolos'), + /Inkarnations-<\/small>Voltolos<\/span>/); + assert.match(renderRow('Thundurus', 'ボルトロス・けしん', 'ボルトロス'), + /ボルトロス・けしん<\/small><\/span>/); + assert.match(renderRow('Greninja-Ash', 'Sachanobi', 'Amphinobi'), + /Sachanobi<\/small><\/span>/); + }); +}); diff --git a/test/battle-text-parser.test.js b/test/battle-text-parser.test.js index 02ea6340f..b1566154a 100644 --- a/test/battle-text-parser.test.js +++ b/test/battle-text-parser.test.js @@ -77,10 +77,12 @@ describe('BattleTextParser', {skip: hasBuiltText ? false : 'text data has not be it('uses canonical language tables with English field fallback', () => { global.BattleText.ja = { Default: {default: {hitCount: '[NUMBER]回 当たった!'}}, - PokedexNames: {ironleaves: 'テツノイサハ'}, + Pokedex: {ironleaves: {name: 'テツノイサハ', baseSpecies: 'テツノイサハ'}}, TypeNames: {fire: 'ほのお'}, NatureNames: {adamant: 'いじっぱり'}, - TermNames: {egggroup: 'タマゴグループ', moves: '技'}, + TermNames: { + egggroup: 'タマゴグループ', moves: '技', nicknamespecies: '[NICKNAME]([SPECIES])', + }, TagNames: {physical: 'ぶつり'}, GenderNames: {female: 'メス'}, EggGroupNames: {humanlike: 'ひとがた'}, @@ -117,13 +119,17 @@ describe('BattleTextParser', {skip: hasBuiltText ? false : 'text data has not be assert.equal(global.Dex.text.colorName('Purple', 'ja'), 'むらさき'); assert.deepEqual( parser.pokemonFull('p1a: Salad', 'Iron Leaves'), - ['p1', 'Salad (**テツノイサハ**)'] + ['p1', 'Salad(**テツノイサハ**)'] ); assert.deepEqual( parser.pokemonFull('p1a: Iron Leaves', 'Iron Leaves'), - ['p1', 'Iron Leaves (**テツノイサハ**)'] + ['p1', 'Iron Leaves(**テツノイサハ**)'] ); assert.deepEqual(parser.pokemonFull('p1a: テツノイサハ', 'Iron Leaves'), ['p1', '**テツノイサハ**']); + assert.equal( + parser.extractMessage('|switch|p1a: Salad|Iron Leaves, L50|100/100'), + 'Go! Salad(**テツノイサハ**)!\n' + ); }); it('falls back by field for past-generation text', () => { @@ -340,14 +346,14 @@ describe('BattleTextParser', {skip: hasBuiltText ? false : 'text data has not be it('uses localized stat grammar', () => { global.BattleText.fr = { Default: {default: {boost: '[STAT:definite:capitalize] de [POKEMON] augmente !'}}, - TermNames: {stats: 'Stats', 'stats:grammar': 'fp'}, - StatNames: {atk: 'Attaque', 'atk:grammar': 'fs'}, + TermNames: {stats: 'Stats'}, + StatNames: {stats: 'stats', 'stats:grammar': 'fp', atk: 'Attaque', 'atk:grammar': 'fs'}, StatMediumNames: {atk: 'Atq.'}, StatShortNames: {atk: 'Atq'}, }; const parser = new BattleTextParser('p1', 'fr'); assert.equal(BattleTextParser.stat('atk', 'fr'), 'Attaque'); - assert.equal(BattleTextParser.stat('', 'fr'), 'Stats'); + assert.equal(BattleTextParser.stat('', 'fr'), 'stats'); assert.equal(BattleTextParser.statMediumName('atk', 'fr'), 'Atq.'); assert.equal(BattleTextParser.statShortName('atk', 'fr'), 'Atq'); assert.equal(parser.extractMessage('|-boost|p1a: Mew|atk|1'), 'L’Attaque de Mew augmente !\n');