Support latest server-data scheme

This commit is contained in:
Guangcong Luo
2026-08-27 03:59:19 -07:00
parent da6e51c7a1
commit a3083ec9fb
6 changed files with 119 additions and 30 deletions

View File

@@ -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++) {

View File

@@ -101,10 +101,12 @@ export class PSSearchResults extends preact.Component<{
const pokemon = search.dex.species.get(id);
if (!pokemon) return `<li class="result" value="${index}">Unrecognized pokemon</li>`;
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)}">` +
`<span class="col numcol">${escapeHTML(search.getTier(pokemon))}</span>` +
`<span class="col iconcol"><span class="pixelated" style="${escapeHTML(Dex.getPokemonIcon(pokemon.id))}"></span></span>` +
`<span class="col pokemonnamecol">${this.renderNameHTML(displayName, matchStart, matchEnd, displayTagStart)}</span>`;
`<span class="col pokemonnamecol">${this.renderNameHTML(
displayName, matchStart, matchEnd, baseStart, baseEnd
)}</span>`;
if (errorMessage) return `${buf}${errorMessage}</a></li>`;
buf += `<span class="col typecol">${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 `<i>(no ability)</i>`;
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)) +
`<b>${escapeHTML(name.slice(boldStart, boldEnd))}</b>` +
escapeHTML(name.slice(boldEnd, end));
};
return (tagStart ? `<small>${renderPart(0, tagStart)}</small>` : '') +
renderPart(tagStart, tagEnd) +
(tagEnd < name.length ? `<small>${renderPart(tagEnd, name.length)}</small>` : '');
}
if (!matchEnd) {
if (!tagStart) return escapeHTML(name);

View File

@@ -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'),

View File

@@ -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 },

View File

@@ -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'),
/<span class="col pokemonnamecol">Thundurus<small>-Incarnate<\/small><\/span>/);
assert.match(renderRow('Thundurus-Therian', 'Thundurus-Therian', 'Thundurus'),
/<span class="col pokemonnamecol">Thundurus<small>-Therian<\/small><\/span>/);
assert.match(renderRow('Mewtwo-Mega-X', 'Mega Mewtwo X', 'Mewtwo'),
/<span class="col pokemonnamecol"><small>Mega <\/small>Mewtwo<small> X<\/small><\/span>/);
assert.match(renderRow('Thundurus', 'Inkarnations-Voltolos', 'Voltolos'),
/<span class="col pokemonnamecol"><small>Inkarnations-<\/small>Voltolos<\/span>/);
assert.match(renderRow('Thundurus', 'ボルトロス・けしん', 'ボルトロス'),
/<span class="col pokemonnamecol">ボルトロス<small>・けしん<\/small><\/span>/);
assert.match(renderRow('Greninja-Ash', 'Sachanobi', 'Amphinobi'),
/<span class="col pokemonnamecol"><small>Sachanobi<\/small><\/span>/);
});
});

View File

@@ -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');