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:
@@ -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)
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user