store: categorías en inglés (términos oficiales sacados de los DB2)

Cierra el idioma de /en/store: los nombres de ítem ya salían en inglés
(2daf920), pero el árbol de categorías seguía en español y la tienda se veía a
medias.

Los términos DEL JUEGO no se traducen a mano, se sacan del cliente, para que
casen con lo que ve un jugador en inglés:

- ChrClasses  -> clases (Brujo = Warlock, Pícaro = Rogue...)
- ItemSet     -> sets (Vestiduras de la Némesis = Nemesis Raiment, Las diez
                 tormentas = The Ten Storms, Atavío viento abisal = Netherwind
                 Regalia). Casando sin distinguir mayúsculas: las categorías
                 capitalizan distinto que los ItemSet.
- ItemSubClass-> tipos de arma (Armas de asta = Polearms, Varitas = Wands)
- home_item_data -> lo que no es ItemSet. Los sets de PvP de WotLK no son
                 ItemSets reales, así que "Gladiador furioso" = "Furious
                 Gladiator" sale de los nombres bilingües de sus propios ítems.

Solo se traduce a mano lo que la tienda se inventó y no existe en el juego
(Items de leveo = Leveling Items, Moneda de transfiguración = Transmog
Currency), más las fórmulas (Nivel de objeto N = Item Level N, Set de banda
tier N = Tier N Raid Set). Los ~45 nombres que son solo un código ("11-1") y
los que ya estaban en inglés ("Tier 7.1 (PvE)") se dejan igual.

wdc_db2.py aprende a leer strings de tablas no sparse: el valor guardado no es
un índice a la string table sino un offset relativo a la posición del propio
campo. Verificado con ChrClasses (las 10 clases en ambos idiomas).

Revisadas las 218 a mano. Corregida una que me había inventado: "Equipo de
Batalla del Iluminado de la Fuente del Sol" no es "Sunwell Radiance"
(eso es un buff): "Iluminado" es Lightbringer en el ItemSet y "Fuente del Sol"
es Sunwell según sus ítems -> "Sunwell Lightbringer Battlegear".

OJO, bug de datos preexistente: "Sacedorte" (errata de "Sacerdote") en las
categorías 3-10, 4-10 y 19-9; el 20-9 sí está bien. Se ve así en español. El
name_en va a "Priest"; la errata en español NO se toca.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 08:45:24 +00:00
parent 2daf920c01
commit bb0f4f21c1
4 changed files with 456 additions and 4 deletions
+202
View File
@@ -0,0 +1,202 @@
"""Traduce los nombres de home_store_category al inglés -> sql/store_category_en.sql.
USO: mysql ... -N -e 'SELECT DISTINCT name FROM home_store_category' \\
| /root/NovaWoW/.venv/bin/python sql/db2/translate_categories.py
Prioridad: términos OFICIALES sacados de los DB2 del cliente (ChrClasses,
ItemSet, ItemSubClass) y de los nombres bilingües de home_item_data. Solo se
traduce a mano lo que la tienda se inventó y no existe en el juego.
"""
import json
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import wdc_db2
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, '..', 'store_category_en.sql')
def _pairs(table, field):
"""Pares es->en de un campo string, casando por id entre los dos locales."""
out = {}
for loc in ('esES', 'enUS'):
t = wdc_db2.DB2(f'/root/{loc}/{table}.db2')
out[loc] = {rid: t.string_at(i, field, row[field]) for i, (rid, row) in enumerate(t.rows)}
return {out['esES'][k]: out['enUS'][k] for k in out['esES']
if k in out['enUS'] and out['esES'][k]}
# Términos OFICIALES del juego, sacados del cliente: nunca inventados.
oficial = {}
oficial.update(_pairs('ChrClasses', 0)) # Name_lang
oficial.update(_pairs('ItemSet', 0)) # Name_lang
oficial.update(_pairs('ItemSubClass', 0)) # DisplayName_lang
oficial.update(_pairs('ItemSubClass', 1)) # VerboseName_lang
# case-insensitive: las categorías capitalizan distinto que los ItemSet
# ("Las Diez Tormentas" vs "Las diez tormentas").
OF = {k.lower(): v for k, v in oficial.items()}
# Términos extraídos de los nombres bilingües de ítem (no están en ItemSet:
# los sets de PvP de WotLK no son ItemSets reales). Verificados con
# `SELECT name_es, name_en FROM home_item_data WHERE name_es LIKE ...`.
OF.update({
'gladiador furioso': 'Furious Gladiator',
'gladiador incansable': 'Relentless Gladiator',
'gladiador indómito': 'Savage Gladiator',
'gladiador mortal': 'Deadly Gladiator',
'gladiador odioso': 'Hateful Gladiator',
})
# Vocabulario de la tienda (no son términos del juego; traducción manual).
ROL = {
'DPS caster': 'Caster DPS', 'DPS melee': 'Melee DPS', 'DPS Sanador': 'Healer DPS',
'DPS': 'DPS', 'Sanador': 'Healer', 'Tanque': 'Tank',
'Cuero': 'Leather', 'Malla': 'Mail', 'Placas': 'Plate', 'Tela': 'Cloth',
'Indómito': 'Savage', 'Mortal': 'Deadly', 'Odioso': 'Hateful',
}
MATERIAL = {'cuero': 'Leather', 'malla': 'Mail', 'placa': 'Plate', 'placas': 'Plate',
'tela': 'Cloth'}
PIEZA = {'Botas': 'Boots', 'Brazales': 'Bracers', 'Cinturones': 'Belts'}
MANUAL = {
'Abalorios': 'Trinkets', 'Anillos': 'Rings', 'Collares': 'Necks', 'Capas': 'Cloaks',
'Hombreras': 'Shoulders', 'Pecheras': 'Chests', 'Tabardos': 'Tabards',
'Escudos': 'Shields', 'Sostener': 'Held In Off-hand', 'Una mano': 'One-Hand',
'Dos manos': 'Two-Hand', 'Joyería': 'Jewelry', 'Armas': 'Weapons',
'Bolsas': 'Bags', 'Bolsa de almas': 'Soul Bag', 'Bolsas de profesiones': 'Profession Bags',
'Contenedores': 'Containers', 'Consumibles': 'Consumables', 'Pociones': 'Potions',
'Elíxires': 'Elixirs', 'Frascos': 'Flasks', 'Pergaminos': 'Scrolls',
'Comidas y bebidas': 'Food & Drinks', 'Esencias y polvos': 'Essences & Powders',
'Hierbas': 'Herbs', 'Minerales': 'Ores', 'Telas': 'Cloth', 'Cueros': 'Leather',
'Mascotas': 'Pets', 'Elementales': 'Elementals', 'Monedas': 'Currencies',
'Monturas terrestres': 'Ground Mounts', 'Monturas voladoras': 'Flying Mounts',
'Piedra hogar': 'Hearthstone', 'Miscelánea': 'Miscellaneous', 'Varios': 'Miscellaneous',
'Mejoras de objetos': 'Item Enhancements', 'Objetos de misión': 'Quest Items',
'Objetos comerciables': 'Trade Goods', 'Misiones repetibles': 'Repeatable Quests',
'Items de leveo': 'Leveling Items', 'Moneda de transfiguración': 'Transmog Currency',
'Tokens de Armadura': 'Armor Tokens',
# "Exalted" verificado con sus propios ítems: "Casco exaltado" = "Exalted Helmet".
'Placa Exaltada': 'Exalted Plate',
'Carcajs | Munición': 'Quivers | Ammo',
# Los DB2 solo traen "Espadas de una mano"/"de dos manos"; la tienda las
# agrupa, así que va el plural genérico del término oficial.
'Espadas': 'Swords', 'Hachas': 'Axes', 'Mazas': 'Maces', 'Arrojadizas': 'Thrown',
# OJO: "Sacedorte" es una errata de "Sacerdote" en home_store_category
# (códigos 3-10, 4-10 y 19-9; el 20-9 sí está bien escrito). Se traduce a
# Priest igualmente; la errata en español sigue ahí.
'Sacedorte': 'Priest',
"Templo de Ahn'Qiraj": "Temple of Ahn'Qiraj",
# El ItemSet oficial se llama "Equipo de batalla DE runaoscura"; la categoría
# se come el "de", así que no casa ni ignorando mayúsculas. El inglés sale
# igualmente del ItemSet.
'Equipo de Batalla Runaoscura': 'Darkruned Battlegear',
# Compuesto de dos términos oficiales: el ItemSet "Equipo de batalla de
# Iluminado" = "Lightbringer Battlegear", y "Fuente del Sol" = "Sunwell"
# (ítem "Hoja de La Fuente del Sol" = "Sunwell Blade"). Son las piezas T6.5
# de paladín. NO es "Sunwell Radiance", que es un buff, no un set.
'Equipo de Batalla del Iluminado de la Fuente del Sol': 'Sunwell Lightbringer Battlegear',
}
def tr(name):
n = name.strip()
# Nombres que son solo un código ("11-1", "23-10"): no son español.
if re.fullmatch(r'[\d.\-]+', n):
return n
# Nombres que ya están en inglés: "Tier 7.1 (PvE)", "Tier 4 (PvE) | Tier 1 (PvP)".
if re.fullmatch(r'Tier [\d.]+ \(Pv[EP]\)( \| Tier [\d.]+ \(Pv[EP]\))?', n):
return n
if n in MANUAL:
return MANUAL[n]
if n.lower() in OF:
return OF[n.lower()]
# "X (sufijo)": traducir cada parte por separado. Si el sufijo no se sabe
# traducir se deja igual ("(T2 Recolor)" ya está en inglés); si no se sabe
# traducir la base, se abandona la regla -> nunca se interpola None.
m = re.fullmatch(r'(.+?) \((.+)\)', n)
if m:
base, suf = m.groups()
b = tr(base)
if b:
return f'{b} ({ROL.get(suf) or tr(suf) or suf})'
rules = [
(r'^Nivel de objeto (.+)$', lambda m: f'Item Level {m.group(1)}'),
(r'^Set de banda tier (.+)$', lambda m: f'Tier {m.group(1)} Raid Set'),
(r'^Set de mazmorra (.+)$', lambda m: f'Dungeon Set {m.group(1)}'),
(r'^Set inicial (.+)$', lambda m: f'Starter Set {m.group(1)}'),
(r'^Set de Temporada de Arenas (.+)$', lambda m: f'Arena Season {m.group(1)} Set'),
(r'^Temporada de Arenas (.+)$', lambda m: f'Arena Season {m.group(1)}'),
(r'^Set del (.+)$', lambda m: f'{tr(m.group(1))} Set' if tr(m.group(1)) else None),
(r'^Reliquia (PvE|PvP)$', lambda m: f'{m.group(1)} Relic'),
(r'^Sets de armadura (PvE|PvP) \[Nivel (.+) a (.+)\]$',
lambda m: f'{m.group(1)} Armor Sets [Level {m.group(2)} to {m.group(3)}]'),
(r'^Sets de armadura (PvE|PvP) \[Nivel (.+)\]$',
lambda m: f'{m.group(1)} Armor Sets [Level {m.group(2)}]'),
(r'^Sets de transfiguración (PvE|PvP) \[Hasta nivel (.+)\]$',
lambda m: f'{m.group(1)} Transmog Sets [Up to level {m.group(2)}]'),
(r'^Armas de transfiguración (PvE|PvP) \[Hasta nivel (.+)\]$',
lambda m: f'{m.group(1)} Transmog Weapons [Up to level {m.group(2)}]'),
(r'^Accesorios de Transfiguración (PvE|PvP) \[Hasta nivel (.+)\]$',
lambda m: f'{m.group(1)} Transmog Accessories [Up to level {m.group(2)}]'),
(r'^Armas (PvE|PvP) \[Nivel (.+) a (.+)\]$',
lambda m: f'{m.group(1)} Weapons [Level {m.group(2)} to {m.group(3)}]'),
(r'^Accesorios (PvE|PvP) \[Nivel (.+) a (.+)\]$',
lambda m: f'{m.group(1)} Accessories [Level {m.group(2)} to {m.group(3)}]'),
# Piezas de armadura: "<Pieza> de <material>" (los datos traen "placa" y
# "placas" indistintamente para lo mismo)
(r'^(Botas|Brazales|Cinturones) de (cuero|malla|placa|placas|tela)$',
lambda m: f'{MATERIAL[m.group(2)]} {PIEZA[m.group(1)]}'),
# "(T2 Recolor)" ya está en inglés: se conserva y se traduce solo la base
(r'^(.+) \(T(\d) Recolor\)$',
lambda m: f'{tr(m.group(1))} (T{m.group(2)} Recolor)' if tr(m.group(1)) else None),
]
for pat, fn in rules:
m = re.fullmatch(pat, n)
if m:
return fn(m)
return None # sin traducir
def esc(s):
return s.replace('\\', '\\\\').replace("'", "''")
if __name__ == '__main__':
names = [l.rstrip('\n') for l in sys.stdin if l.strip()]
if not names:
sys.exit('Pásame los nombres por stdin:\n'
" mysql ... -N -e 'SELECT DISTINCT name FROM home_store_category' | "
'python translate_categories.py')
out, missing = {}, []
for n in names:
t = tr(n)
(missing.append(n) if t is None else out.__setitem__(n, t))
if missing:
print('SIN TRADUCIR (%d):' % len(missing), file=sys.stderr)
for m in missing:
print(' ', m, file=sys.stderr)
bad = {k: v for k, v in out.items() if not v or 'None' in str(v)}
if bad:
sys.exit(f'ERROR: traducciones corruptas: {bad}')
with open(OUT, 'w', encoding='utf-8') as f:
f.write("""-- Nombres en inglés de las categorías de la tienda. NO editar a mano:
-- regenerar con sql/db2/translate_categories.py (ver cabecera del script).
-- Los términos del JUEGO salen de los DB2 del cliente (ChrClasses, ItemSet,
-- ItemSubClass): así casan con lo que ve un jugador en inglés. Solo se traduce
-- a mano lo que la tienda se inventó y no existe en el juego.
ALTER TABLE `home_store_category`
ADD COLUMN `name_en` varchar(191) DEFAULT NULL COMMENT 'nombre en inglés; NULL = usar name' AFTER `name`;
""")
for k, v in sorted(out.items()):
f.write("UPDATE `home_store_category` SET `name_en` = '%s' WHERE `name` = '%s';\n"
% (esc(v), esc(k)))
print(f'escritas {len(out)} categorías en {OUT}', file=sys.stderr)
+18
View File
@@ -91,6 +91,7 @@ class DB2:
self.common[fi] = m
self.rows = []
self.row_pos = [] # posición en fichero de cada fila (para string_at)
for s in self.sections:
self._read_section(s)
@@ -99,6 +100,7 @@ class DB2:
o = s['file_offset']
if self.flags & 0x1: # sparse: offset map en vez de registros fijos
raise NotImplementedError('tabla sparse (offset map) no soportada')
rec_start = o
rec_data = d[o:o + s['record_count'] * self.record_size]
o += s['record_count'] * self.record_size
o += s['string_table_size']
@@ -110,6 +112,9 @@ class DB2:
for i in range(s['record_count']):
rec = rec_data[i * self.record_size:(i + 1) * self.record_size]
# Posición del registro en el fichero: la necesita string_at, porque
# los offsets de los strings son relativos al propio campo.
self.row_pos.append(rec_start + i * self.record_size)
row = [self._field(rec, fi, i) for fi in range(self.field_count)]
rid = ids[i] if ids is not None else row[self.id_index]
# common data: valor por id, o el default (c1) si no está listado
@@ -141,3 +146,16 @@ class DB2:
return struct.unpack_from('<I', self.pallet, off)[0]
return 0
raise NotImplementedError(f'storage type {t}')
def string_at(self, row_index, fi, value):
"""Resuelve un campo string de una tabla NO sparse.
El valor guardado no es un offset absoluto ni un índice a la string
table: es un offset RELATIVO a la posición del propio campo dentro del
fichero. 0 = cadena vacía.
"""
if not value:
return ''
pos = self.row_pos[row_index] + self.storage[fi]['offset_bits'] // 8 + value
end = self.d.find(b'\x00', pos)
return self.d[pos:end].decode('utf-8', 'replace')