Support translated battles (#2740)
Some checks failed
Node.js CI / build (22.x) (push) Has been cancelled

This commit is contained in:
Guangcong Luo
2026-08-22 20:34:58 -07:00
committed by GitHub
parent ac7d535b28
commit 8aac6055f1
21 changed files with 955 additions and 511 deletions

View File

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

View File

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

View File

@@ -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" },
],
};
/**

View File

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

View File

@@ -115,8 +115,7 @@ https://psim.us/dev
<script defer src="/js/battle-sound.js"></script>
<script defer src="/js/lib/jquery-2.2.4.min.js"></script>
<script defer src="/data/graphics.js?"></script>
<script defer src="/data/text.js?"></script>
<script defer src="/data/text-afd.js?"></script>
<script defer src="/data/text/en.js?"></script>
<script defer src="/js/battle-tooltips.js?"></script>
<script defer src="/js/battle.js?"></script>
<script defer src="/js/battle-choices.js?"></script>

View File

@@ -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<void> } = { 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<void> {
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<void>((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);

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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' ? '<small>' : '<small> / ';
buf += `${BattleText[statLabel].statShortName}&nbsp;</small>`;
buf += `${BattleTextParser.statShortName(statLabel)}&nbsp;</small>`;
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' ? '<small>' : '<small> / ';
buf += `${BattleText[statLabel].statShortName}&nbsp;</small>`;
buf += `${BattleTextParser.statShortName(statLabel)}&nbsp;</small>`;
if (modifiedStats[statName] === stats[statName]) {
buf += `${modifiedStats[statName]}`;
} else if (modifiedStats[statName] < stats[statName]) {

View File

@@ -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<string | null> {
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<string | null> {
}
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<Args | null> 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-'));

View File

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

View File

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

View File

@@ -550,8 +550,9 @@
"Türkçe": 'turkish',
"हिंदी": 'hindi',
"日本語": 'japanese',
"한국어": 'korean',
"简体中文": 'simplifiedchinese',
"中文": 'traditionalchinese'
"繁體中文": 'traditionalchinese'
};
buf += '<p><label class="optlabel">Language: <select name="language" class="button">';
for (var name in possibleLanguages) {
@@ -631,7 +632,14 @@
Storage.prefs('tournaments', tournaments);
},
setLanguage: function (e) {
app.user.updateSetting('language', e.currentTarget.value);
var language = e.currentTarget.value;
app.user.updateSetting('language', language);
Dex.loadTextData().then(function () {
for (var roomid in app.rooms) {
var battle = app.rooms[roomid] && app.rooms[roomid].battle;
if (battle) battle.resetToCurrentTurn();
}
});
},
setBlockpms: function (e) {
app.user.updateSetting('blockPMs', !!e.currentTarget.checked);

View File

@@ -742,20 +742,6 @@ function toId() {
},
setAFD: function (mode) {
if (mode === undefined) {
// init
if (typeof BattleTextAFD !== 'undefined') {
for (var id in BattleTextNotAFD) {
if (!BattleTextAFD[id]) {
BattleTextAFD[id] = BattleTextNotAFD[id];
} else {
var combined = {};
Object.assign(combined, BattleTextNotAFD[id]);
Object.assign(combined, BattleTextAFD[id]);
BattleTextAFD[id] = combined;
}
}
}
if (Config.server.afd) {
mode = true;
} else if (Dex.prefs('afd') !== undefined) {
@@ -767,12 +753,7 @@ function toId() {
}
Dex.afdMode = mode;
if (mode === true) {
BattleText = BattleTextAFD;
} else {
BattleText = BattleTextNotAFD;
}
if (mode === true) Dex.loadTextData('en-afd');
},
/**
* This function establishes the actual connection to the sim server.

View File

@@ -147,6 +147,7 @@ export class MainMenuRoom extends PSRoom {
if (named) PS.user.initializing = false;
if (settingsJSON) {
PS.prefs.set('serversettings', { ...PS.prefs.serversettings, ...JSON.parse(settingsJSON) });
Dex.loadTextData();
}
PS.user.setName(fullName, named, avatar);
PS.teams.loadRemoteTeams();

View File

@@ -656,6 +656,12 @@ class OptionsPanel extends PSRoomPanel {
}
case 'language': {
PS.prefs.set('serversettings', { ...PS.prefs.serversettings, language: elem.value });
void Dex.loadTextData().then(() => {
for (const roomid in PS.rooms) {
const battle = (PS.rooms[roomid] as BattleRoom)?.battle;
if (battle) battle.resetToCurrentTurn();
}
});
PS.send(`/language ${elem.value}`);
break;
}
@@ -696,7 +702,29 @@ class OptionsPanel extends PSRoomPanel {
<button className="button" data-href="register">Register</button>)}
<hr />
<h3>Graphics</h3>
<h3>General</h3>
<p>
<label class="optlabel">
<i class="fa fa-globe" aria-hidden></i> Language: {}
<select name="language" onChange={this.handleOnChange} class="select" value={serverSettings.language || 'english'}>
<option value="english">English</option>
<option value="german">Deutsch</option>
<option value="spanish">Español</option>
<option value="french">Français</option>
<option value="italian">Italiano</option>
<option value="dutch">Nederlands</option>
<option value="portuguese">Português</option>
<option value="turkish">Türkçe</option>
<option value="hindi">ि</option>
<option value="japanese"></option>
<option value="korean"></option>
<option value="simplifiedchinese"></option>
<option value="traditionalchinese"></option>
</select>
</label>
</p>
<hr />
<h3>Appearance</h3>
<p>
<label class="optlabel">Theme: <select
name="theme" class="select" onChange={this.setTheme} value={PS.prefs.theme || 'light'}
@@ -774,25 +802,6 @@ class OptionsPanel extends PSRoomPanel {
name="refreshprompt" checked={PS.prefs.refreshprompt || false} type="checkbox" onChange={this.handleOnChange}
/> Confirm before refreshing</label>
</p>
<p>
<label class="optlabel">
Language: {}
<select name="language" onChange={this.handleOnChange} class="select" value={serverSettings.language || 'english'}>
<option value="english">English</option>
<option value="german">Deutsch</option>
<option value="spanish">Español</option>
<option value="french">Français</option>
<option value="italian">Italiano</option>
<option value="dutch">Nederlands</option>
<option value="portuguese">Português</option>
<option value="turkish">Türkçe</option>
<option value="hindi">ि</option>
<option value="japanese"></option>
<option value="simplifiedchinese"></option>
<option value="traditionalchinese"></option>
</select>
</label>
</p>
<p>
<label class="optlabel">
Tournaments: <select

View File

@@ -138,8 +138,7 @@
<script src="js/battle-sound.js"></script>
<script src="js/lib/jquery-2.2.4.min.js"></script>
<script src="data/graphics.js" onerror="loadRemoteData(this.src)"></script>
<script src="data/text.js" onerror="loadRemoteData(this.src)"></script>
<script src="data/text-afd.js" onerror="loadRemoteData(this.src)"></script>
<script src="data/text/en.js" onerror="loadRemoteData(this.src)"></script>
<script src="data/commands.js?" onerror="loadRemoteData(this.src)"></script>
<script src="js/battle-tooltips.js"></script>
<script src="js/battle.js"></script>

View File

@@ -90,7 +90,7 @@
<!-- fallback for if you've never done a full build -->
<script src="js/server/chat-formatter.js" onerror="loadRemoteData(this.src)"></script>
<script src="js/battledata.js" onerror="loadRemoteData(this.src)"></script>
<script src="data/text.js" onerror="loadRemoteData(this.src)"></script>
<script src="data/text/en.js" onerror="loadRemoteData(this.src)"></script>
<script src="data/pokedex-mini.js" onerror="loadRemoteData(this.src)"></script>
<script src="data/pokedex-mini-bw.js" onerror="loadRemoteData(this.src)"></script>
<script src="data/typechart.js" onerror="loadRemoteData(this.src)"></script>

View File

@@ -0,0 +1,202 @@
const assert = require('assert').strict;
const {describe, it} = require('node:test');
const fs = require('fs');
const path = require('path');
const englishTextPath = path.resolve(__dirname, '../play.pokemonshowdown.com/data/text/en.js');
const afdTextPath = path.resolve(__dirname, '../play.pokemonshowdown.com/data/text/en-afd.js');
const hasBuiltText = fs.existsSync(englishTextPath) && fs.existsSync(afdTextPath);
let BattleTextParser;
if (hasBuiltText) {
global.window = global;
global.BattleText = require(englishTextPath).BattleText;
global.BattleText['en-afd'] = require(afdTextPath).BattleText['en-afd'];
global.BattleText.en.Default.default.hitCount =
" The Pok\u00E9mon was hit [NUMBER] [INFLECT:NUMBER:s=time:p=times]!";
global.BattleText.en.Default.unboost.fail =
" [POKEMON]'s [STAT] [INFLECT:STAT:s=was:p=were] not lowered!";
require('../play.pokemonshowdown.com/js/battle-dex-data.js');
require('../play.pokemonshowdown.com/js/battle-dex.js');
require('../play.pokemonshowdown.com/js/battle-text-parser.js');
BattleTextParser = global.BattleTextParser;
}
describe('BattleTextParser', {skip: hasBuiltText ? false : 'text data has not been built'}, () => {
it('uses en-afd as a sparse English overlay', () => {
global.Dex.afdMode = true;
const parser = new BattleTextParser();
assert.equal(parser.extractMessage('|-hitcount|p1a: Mew|3'), ' Hit 3 times!\n');
global.Dex.afdMode = false;
});
it('uses canonical language tables with English field fallback', () => {
global.BattleText.ja = {
Default: {default: {hitCount: '[NUMBER]回 当たった!'}},
Pokedex: {ironleaves: {name: 'テツノイサハ'}},
Moves: {knockoff: {name: 'はたきおとす'}},
Abilities: {levitate: {name: 'ふゆう'}},
Items: {lifeorb: {name: 'いのちのたま'}},
};
const parser = new BattleTextParser('p1', 'ja');
assert.equal(parser.extractMessage('|-hitcount|p1a: Mew|3'), '3回 当たった!\n');
assert.equal(parser.pokemon('p1a: Mew'), 'Mew');
});
it('translates effect and species names without translating nicknames', () => {
const parser = new BattleTextParser('p1', 'ja');
assert.equal(parser.moveName('Knock Off'), 'はたきおとす');
assert.equal(parser.itemName('Life Orb'), 'いのちのたま');
assert.equal(parser.abilityName('Levitate'), 'ふゆう');
assert.deepEqual(
parser.pokemonFull('p1a: Salad', 'Iron Leaves'),
['p1', 'Salad (**テツノイサハ**)']
);
assert.deepEqual(
parser.pokemonFull('p1a: Iron Leaves', 'Iron Leaves'),
['p1', 'Iron Leaves (**テツノイサハ**)']
);
assert.deepEqual(parser.pokemonFull('p1a: テツノイサハ', 'Iron Leaves'), ['p1', '**テツノイサハ**']);
});
it('falls back by field for past-generation text', () => {
global.BattleText.en.Moves.testmove = {start: 'modern', gen4: {start: 'old'}};
global.BattleText.ja.Moves.testmove = {start: '現代'};
const parser = new BattleTextParser('p1', 'ja');
parser.gen = 4;
assert.equal(parser.template('start', 'move: Test Move'), 'old\n');
});
it('Dex.text.get returns localized entries with English fallback', () => {
global.BattleText.en.Moves.testmove = {name: 'Test Move', desc: 'English description'};
global.BattleText.ja.Moves.testmove = {name: 'テスト技'};
const move = new global.Dex.Move('testmove', 'Test Move', {});
assert.deepEqual(global.Dex.text.get(move, 'ja'), {
name: 'テスト技', desc: 'English description',
});
});
it('selects and unescapes INFLECT values', () => {
const template = String.raw`[INFLECT:ITEM:s=one:p=two\:three\=four\]five\\six]`;
assert.equal(BattleTextParser.inflect(template, {ITEM: 's'}), 'one');
assert.equal(BattleTextParser.inflect(template, {ITEM: 'p'}), 'two:three=four]five\\six');
});
it('leaves unresolved INFLECT expressions visible', () => {
const template = '[INFLECT:ITEM:s=one:p=many]';
assert.equal(BattleTextParser.inflect(template, {}), template);
assert.equal(BattleTextParser.inflect(template, {ITEM: 'z'}), template);
});
it('inflects hit counts', () => {
const parser = new BattleTextParser();
assert.equal(parser.extractMessage('|-hitcount|p1a: Mew|1'), ' The Pok\u00E9mon was hit 1 time!\n');
assert.equal(parser.extractMessage('|-hitcount|p1a: Mew|3'), ' The Pok\u00E9mon was hit 3 times!\n');
});
it('lets translations place the percentage symbol', () => {
global.BattleText.en.Default.default.damagePercentage =
' ([POKEMON] lost [PERCENTAGE]% of its health!)';
const parser = new BattleTextParser();
assert.equal(
parser.extractMessage('|-damage|p1a: Mew|58/100|42%'),
' (Mew lost 42% of its health!)\n'
);
assert.equal(parser.extractMessage('|-damage|p1a: Mew|58/100'), ' (Mew was hurt!)\n');
});
it('inflects single-stat and all-stat failures', () => {
const parser = new BattleTextParser();
assert.equal(parser.extractMessage('|-fail|p1a: Mew|unboost|atk'), " Mew's Attack was not lowered!\n");
assert.equal(parser.extractMessage('|-fail|p1a: Mew|unboost'), " Mew's stats were not lowered!\n");
});
it('applies placeholder modifiers after substitution', () => {
global.BattleText.fr = {Default: {default: {
faint: "[POKEMON] voit le clone [POKEMON:de] disparaître !",
}}};
const parser = new BattleTextParser('p1', 'fr');
assert.equal(parser.extractMessage('|faint|p1a: Mew'), 'Mew voit le clone de Mew disparaître !\n');
assert.equal(parser.extractMessage('|faint|p1a: Eevee'), 'Eevee voit le clone dEevee disparaître !\n');
});
it('renders repeated placeholders with distinct values and literal dollar signs', () => {
const original = global.BattleText.en.Default.default.startBattle;
global.BattleText.en.Default.default.startBattle = '[TRAINER] vs. [TRAINER]... start!';
const parser = new BattleTextParser();
assert.equal(parser.extractMessage('|player|p1|Alice$1'), '');
assert.equal(parser.extractMessage('|player|p2|Bob$2'), '');
assert.equal(parser.extractMessage('|start'), 'Alice$1 vs. Bob$2... start!\n');
global.BattleText.en.Default.default.startBattle = original;
});
it('applies Korean placeholder particles', () => {
global.BattleText.ko = {Default: {default: {
faint: '[POKEMON:topic] 쓰러졌다!',
}}};
const parser = new BattleTextParser('p1', 'ko');
assert.equal(parser.extractMessage('|faint|p1a: 뮤'), '뮤는 쓰러졌다!\n');
assert.equal(parser.extractMessage('|faint|p1a: 팬텀'), '팬텀은 쓰러졌다!\n');
});
it('applies articles, contractions, and capitalization', () => {
assert.equal(BattleTextParser.modify('Baie Oran', ['indefinite'], 'fr', 'fs'), 'une Baie Oran');
assert.equal(BattleTextParser.modify('Angriff', ['definite', 'accusative'], 'de'), 'den Angriff');
assert.equal(BattleTextParser.modify('avversario', ['a', 'definite'], 'it'), "allavversario");
assert.equal(BattleTextParser.modify('equipo', ['capitalize'], 'es'), 'Equipo');
assert.equal(
BattleTextParser.modify('Agua Mística', ['definite'], 'es', 'fu', 'stressed-a'),
'el Agua Mística'
);
assert.equal(
BattleTextParser.modify('Aguas Místicas', ['definite'], 'es', 'fp', 'stressed-a'),
'las Aguas Místicas'
);
});
it('uses localized grammar and classified item forms', () => {
global.BattleText.fr = {
Default: {default: {
addItem: '[POKEMON] obtient [ITEM:indefinite:classified] !',
useGem: '[ITEM:definite:capitalize:classified] agi[INFLECT:ITEM:s=t:p=ssent] !',
}},
Items: {brightpowder: {
name: 'Poudre Claire', grammar: 'fu',
classified: {name: 'sac de Poudre Claire', grammar: 'ms'},
}},
};
const parser = new BattleTextParser('p1', 'fr');
assert.equal(
parser.extractMessage('|-item|p1a: Mew|Bright Powder|[from] move: Not Real'),
'Mew obtient un sac de Poudre Claire !\n'
);
assert.equal(
parser.extractMessage('|-enditem|p1a: Mew|Bright Powder|[from] gem|[move] Tackle'),
'Le sac de Poudre Claire agit !\n'
);
});
it('uses localized article rules', () => {
global.BattleText.es = {
Default: {default: {activateItem: '[ITEM:definite:capitalize] actúa!'}},
Items: {mysticwater: {
name: 'Agua Mística', grammar: 'fu', articleRule: 'stressed-a',
}},
};
const parser = new BattleTextParser('p1', 'es');
assert.equal(
parser.extractMessage('|-enditem|p1a: Mew|Mystic Water'),
'El Agua Mística actúa!\n'
);
});
it('uses localized stat grammar', () => {
global.BattleText.fr = {Default: {
default: {boost: '[STAT:definite:capitalize] de [POKEMON] augmente !'},
atk: {statName: 'Attaque', grammar: 'fs'},
}};
const parser = new BattleTextParser('p1', 'fr');
assert.equal(parser.extractMessage('|-boost|p1a: Mew|atk|1'), 'LAttaque de Mew augmente !\n');
});
});

View File

@@ -6,7 +6,7 @@ window = global;
require('../play.pokemonshowdown.com/js/battle-dex-data.js');
require('../play.pokemonshowdown.com/js/battle-dex.js');
require('../play.pokemonshowdown.com/js/battle-scene-stub.js');
// global.BattleText = require('../play.pokemonshowdown.com/data/text.js').BattleText;
// global.BattleText = require('../play.pokemonshowdown.com/data/text/en.js').BattleText;
require('../play.pokemonshowdown.com/js/battle-text-parser.js');
require('../play.pokemonshowdown.com/js/battle.js');

View File

@@ -6,7 +6,7 @@ const path = require('path');
process.chdir(path.resolve(__dirname, '..'));
require(`../play.pokemonshowdown.com/js/battle-dex.js`); // only needed for toID
global.BattleText = require(`../play.pokemonshowdown.com/data/text.js`).BattleText;
global.BattleText = require(`../play.pokemonshowdown.com/data/text/en.js`).BattleText;
require(`../play.pokemonshowdown.com/js/battle-text-parser.js`);
const parser = new BattleTextParser();