diff --git a/web-next/lib/store.ts b/web-next/lib/store.ts index 54af645..5a6b2a8 100644 --- a/web-next/lib/store.ts +++ b/web-next/lib/store.ts @@ -59,18 +59,23 @@ interface ItemRow extends RowDataPacket { * * El nombre del ítem sale de `home_item_data` (extraído de los DB2 del cliente * 3.4.3, ver sql/db2/), que es el ÚNICO sitio con el nombre en inglés: el - * `home_store_item.name` del catálogo original es solo español. Si falta el - * nombre en ese idioma se cae al del catálogo, que siempre está. + * `home_store_item.name` del catálogo original es solo español. El de la + * categoría sale de `home_store_category.name_en` (ver sql/store_category_en.sql). + * En ambos casos, si falta el nombre en ese idioma se cae al del catálogo, que + * siempre está. */ export async function getStoreCatalog(locale: string): Promise { // Solo se interpola el nombre de la COLUMNA, y sale de una lista cerrada: // nunca del locale crudo (que viene de la URL). - const nameCol = locale === 'en' ? 'name_en' : 'name_es' + const en = locale === 'en' + const nameCol = en ? 'name_en' : 'name_es' + const catNameCol = en ? 'name_en' : 'name' let cats: CatRow[] = [] let items: ItemRow[] = [] try { ;[cats] = await db(DB.default).query( - 'SELECT code, parent_code, name, depth, sort FROM home_store_category ORDER BY sort', + `SELECT code, parent_code, COALESCE(NULLIF(${catNameCol}, ''), name) AS name, depth, sort + FROM home_store_category ORDER BY sort`, ) ;[items] = await db(DB.default).query( `SELECT i.id, i.category_code, i.item_id, COALESCE(NULLIF(d.${nameCol}, ''), i.name) AS name, diff --git a/web-next/sql/db2/translate_categories.py b/web-next/sql/db2/translate_categories.py new file mode 100644 index 0000000..ac4b86d --- /dev/null +++ b/web-next/sql/db2/translate_categories.py @@ -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: " de " (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) diff --git a/web-next/sql/db2/wdc_db2.py b/web-next/sql/db2/wdc_db2.py index 7916b02..2cd7350 100644 --- a/web-next/sql/db2/wdc_db2.py +++ b/web-next/sql/db2/wdc_db2.py @@ -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('