import hashlib import re from concurrent.futures import ThreadPoolExecutor from itertools import chain from typing import Any, Dict, List, Optional, Tuple import requests # =============== CLASES =============== class Color: __slots__ = ("id", "index", "club", "selectable", "hex_code") def __init__( self, id: int, index: int, club: int, selectable: bool, hex_code: str ) -> None: self.id = id self.index = index self.club = club self.selectable = selectable self.hex_code = hex_code def __repr__(self) -> str: return f"" def __eq__(self, other) -> bool: return isinstance(other, Color) and self.id == other.id def __hash__(self) -> int: return hash(self.id) class Palette: __slots__ = ("id", "colors") def __init__(self, id: int, colors: List[Color]) -> None: self.id = id self.colors = colors def __repr__(self) -> str: return f"" def get_color_by_id(self, color_id: int) -> Optional[Color]: for c in self.colors: if c.id == color_id: return c return None def get_selectable_colors(self) -> List[Color]: return [c for c in self.colors if c.selectable] class Part: __slots__ = ("id", "type", "colorable", "index", "colorindex") def __init__( self, id: int, type: str, colorable: bool = False, index: int = 0, colorindex: int = 0, ) -> None: self.id = id self.type = type self.colorable = colorable self.index = index self.colorindex = colorindex def __repr__(self) -> str: return f"<<>>" def __eq__(self, other) -> bool: if not isinstance(other, Part): return False return ( self.id == other.id and self.type == other.type and self.colorindex == other.colorindex ) def __hash__(self) -> int: return hash((self.id, self.type, self.colorindex)) class Lib: __slots__ = ( "id", "parts", "gender", "club", "colorable", "selectable", "preselectable", "sellable", "paleta", "type", ) def __init__( self, id: int, parts: list[Part], gender: str | None = None, club: int = 0, colorable: bool = False, selectable: bool = False, preselectable: bool = False, sellable: bool = False, paleta: int = -1, type: str = "Desconocido", ) -> None: self.id = id self.parts = parts self.gender = gender self.club = club self.colorable = colorable self.selectable = selectable self.preselectable = preselectable self.sellable = sellable self.paleta = paleta self.type = type def __repr__(self) -> str: return f"ID:{self.id},partes:{len(self.parts)},gender:{self.gender}, paleta:{self.paleta},Tipo:{self.type}" def __eq__(self, other) -> bool: return isinstance(other, Lib) and self.parts == other.parts def set_paleta(self, pid): self.paleta = pid def copy(self): return Lib( id=self.id, parts=self.parts.copy(), gender=self.gender, club=self.club, colorable=self.colorable, selectable=self.selectable, preselectable=self.preselectable, sellable=self.sellable, paleta=self.paleta, type=self.type, ) class Full(Lib): __slots__ = ("lib_id",) def __init__(self, obj: Lib, lib_id: str) -> None: super().__init__( id=obj.id, parts=obj.parts, gender=obj.gender, club=obj.club, colorable=obj.colorable, selectable=obj.selectable, preselectable=obj.preselectable, sellable=obj.sellable, paleta=obj.paleta, type=obj.type, ) self.lib_id = lib_id def __repr__(self) -> str: return f"ID:{self.id},partes:{len(self.parts)},Lib:{self.lib_id},Paleta:{self.paleta}" # =============== FUNCIONES AUXILIARES =============== def link_parts(partes: list[dict]) -> list[Part]: return [ Part( id=p["id"], type="hr" if p["type"] == "hrb" else p["type"], colorable=p.get("colorable", False), index=p.get("index", 0), colorindex=p.get("colorindex", 0), ) for p in partes ] def link_colors(colors_data: list[dict]) -> list[Color]: return [ Color( id=c["id"], index=c["index"], club=c["club"], selectable=c["selectable"], hex_code=c["hexCode"], ) for c in colors_data ] def link_palettes(palettes_data: list[dict]) -> list[Palette]: return [Palette(p["id"], link_colors(p["colors"])) for p in palettes_data] def hook(info: dict) -> Any: if "parts" in info: parts = link_parts(info["parts"]) return Lib( id=info["id"], parts=parts, gender=info.get("gender"), club=info.get("club", 0), colorable=info.get("colorable", False), selectable=info.get("selectable", False), preselectable=info.get("preselectable", False), sellable=info.get("sellable", False), ) return info def fetch_json(url: str) -> Any: response = requests.get(url.strip()) response.raise_for_status() return response.json(object_hook=hook) # =============== CARGA DINÁMICA =============== def load_game_data( config_url: str, extra_vars: Dict[str, str] = None ) -> tuple[list[Lib], list[Lib], list[Palette], dict]: """ Carga datos desde renderer-config.json. - extra_vars sobrescribe cualquier clave del JSON. - Convierte %libname% → {libname}. - Devuelve (figuremap, figuredata_flat, palettes, config_procesado). """ if extra_vars is None: extra_vars = {} # 1. Descargar config resp = requests.get(config_url.strip()) resp.raise_for_status() raw_config = resp.json() # 2. 🔥 APLICAR SOBREESCRITURA TOTAL config_with_overrides = {} for key in raw_config: config_with_overrides[key] = extra_vars.get(key, raw_config[key]) for key in extra_vars: if key not in config_with_overrides: config_with_overrides[key] = extra_vars[key] # 3. Preprocesar: convertir %libname% → {libname} def convert_percent_to_format(template: str) -> str: return re.sub(r"%([^%]+)%", r"{\1}", template) def preprocess_config(obj): if isinstance(obj, str): return convert_percent_to_format(obj) elif isinstance(obj, list): return [preprocess_config(item) for item in obj] elif isinstance(obj, dict): return {k: preprocess_config(v) for k, v in obj.items()} else: return obj config = preprocess_config(config_with_overrides) # 4. Resolver variables recursivamente context = {} all_keys = set(config.keys()) for key in all_keys: value = config[key] if isinstance(value, str) and not re.search(r"\$\{", value): context[key] = value.strip() changed = True while changed: changed = False for key in all_keys: value = config[key] if isinstance(value, str) and "${" in value: def replace_var(match): var_name = match.group(1) return context.get(var_name, match.group(0)) resolved = re.sub(r"\$\{([^}]+)\}", replace_var, value).strip() if "${" not in resolved and context.get(key) != resolved: context[key] = resolved changed = True # 5. Detectar variables raíz faltantes def escape_html(text: str) -> str: return ( text.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace('"', """) .replace("'", "'") ) def debug_config(original_config: dict, ctx: dict) -> str: def resolve_value(value): if isinstance(value, str): def repl(m): var = m.group(1) return ctx.get(var, f"${{{var}}}") return re.sub(r"\$\{([^}]+)\}", repl, value) elif isinstance(value, list): return [resolve_value(v) for v in value] elif isinstance(value, dict): return {k: resolve_value(v) for k, v in value.items()} else: return value resolved = resolve_value(original_config) import json return json.dumps(resolved, indent=2, ensure_ascii=False) all_missing_vars = set() for key in ["avatar.figuremap.url", "avatar.figuredata.url"]: tpl = config.get(key, "") if isinstance(tpl, str): vars_in_tpl = re.findall(r"\$\{([^}]+)\}", tpl) all_missing_vars.update(vars_in_tpl) root_missing = set() visited = set() def find_root_vars(var_name): if var_name in visited: return visited.add(var_name) if var_name in context: return if var_name not in config: root_missing.add(var_name) return value = config[var_name] if isinstance(value, str): deps = re.findall(r"\$\{([^}]+)\}", value) for dep in deps: find_root_vars(dep) for var in all_missing_vars: find_root_vars(var) if root_missing: debug_str = debug_config(config, context) safe_debug = escape_html(debug_str) raise ValueError( f"Variables raíz no resueltas: {sorted(root_missing)}.\n\n" f"
{safe_debug}
\n\n" f"Pásalas en la URL como ?{'&'.join(f'{v}=...' for v in sorted(root_missing))}" ) # 6. Obtener URLs reales figuremap_url = context.get("avatar.figuremap.url") figuredata_url = context.get("avatar.figuredata.url") if not figuremap_url or not figuredata_url: raise ValueError("No se pudieron resolver las URLs de FigureMap o FigureData") # 7. Descargar datos with ThreadPoolExecutor(max_workers=2) as executor: future_map = executor.submit(fetch_json, figuremap_url) future_data = executor.submit(fetch_json, figuredata_url) figuremap_dict = future_map.result() figuredata_dict = future_data.result() # 8. Procesar def setPaleta(set_) -> List[Lib]: v: List[Lib] = set_["sets"] for i in range(len(v)): v[i].set_paleta(set_["paletteId"]) v[i].type = set_["type"] return v figuremap_raw: List[Lib] = figuremap_dict["libraries"] figuremap_by_id = {lib.id: lib for lib in figuremap_raw} figuredata_flat: List[Lib] = list( chain.from_iterable(setPaleta(set_) for set_ in figuredata_dict["setTypes"]) ) figuremap = [] for lib in figuremap_raw: # Buscar en figuredata_flat el mismo ID matching_data = None for data_lib in figuredata_flat: if data_lib.id == lib.id: matching_data = data_lib break if matching_data and matching_data.paleta != -1: lib.set_paleta(matching_data.paleta) if matching_data and matching_data.type != "Desconocido": lib.type = matching_data.type figuremap.append(lib) palettes: List[Palette] = link_palettes(figuredata_dict["palettes"]) def resolve_config_variables(config_dict: dict, context: dict) -> dict: """Resuelve todas las ${var} en todo el config usando el contexto.""" def resolve_value(value): if isinstance(value, str): def repl(match): var = match.group(1) return context.get(var, match.group(0)) return re.sub(r"\$\{([^}]+)\}", repl, value) elif isinstance(value, list): return [resolve_value(v) for v in value] elif isinstance(value, dict): return {k: resolve_value(v) for k, v in value.items()} else: return value return resolve_value(config_dict) resolved_config = resolve_config_variables(config, context) return figuremap, figuredata_flat, palettes, resolved_config # =============== FUNCIONES PÚBLICAS =============== pruebas = {"hair": "hr", "trousers": "lg", "hat": "ha"} def return_correct(name): for started in pruebas.keys(): if name.startswith(started): return pruebas[started] return "Desconocido" def get_all_part_types_from_data( figuremap, figuredata_flat, include_all=True ) -> tuple[list[str], dict]: _category_index = {} if include_all: _category_index["Todos"] = [] def parts_base_key(lib): return tuple((p.id, p.type) for p in lib.parts) # Indexar ambos conjuntos figuremap_keys = set() figuremap_dict = {} for item in figuremap: key = parts_base_key(item) figuremap_keys.add(key) figuremap_dict[key] = item figuredata_by_base_key = {} for lib in figuredata_flat: key = parts_base_key(lib) if key not in figuredata_by_base_key: figuredata_by_base_key[key] = lib # 1. Ítems normales (en ambos) for item in figuremap: key = parts_base_key(item) if key in figuredata_by_base_key: matched_lib = figuredata_by_base_key[key] full = Full(matched_lib.copy(), str(item.id)) # full.set_paleta(matched_lib.paleta) full.set_paleta(matched_lib.paleta) full.type = matched_lib.type if full.type == "Desconocido": if len(set([t.type for t in full.parts])) == 1: full.type = full.parts[0].type else: full.type = return_correct(full.lib_id) if full.type == "Desconocido": full.type = full.parts[0].type print(full.lib_id, "->", full.type) # Encontrar la parte con mayor prioridad if full.type not in _category_index: _category_index[full.type] = [] _category_index[full.type].append(full) if include_all: _category_index["Todos"].append(full) # 2. Ítems EXTRA (solo en figuredata) extra_items = [] for lib in figuredata_flat: key = parts_base_key(lib) if key not in figuremap_keys: full = Full(lib.copy(), str(lib.id)) # ID 0 para ítems extra extra_items.append(full) if extra_items: _category_index["extra"] = extra_items # 3. Ítems MISSING (solo en figuremap) missing_items = [] for item in figuremap: key = parts_base_key(item) if key not in figuredata_by_base_key: # Crear un objeto Full "vacío" con datos mínimos dummy_lib = Lib( 0, item.parts, ) full = Full(dummy_lib, str(item.id)) missing_items.append(full) if missing_items: _category_index["missing"] = missing_items return sorted(_category_index.keys()), _category_index