diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ea186a..8bddd07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,27 @@ jobs: - name: Puerta de calidad (ruff, pyright y pytest con el validador Java) run: uv run python scripts/verificar.py + - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: "25" # Minecraft 26.x (Fabric 26.1) pide Java 25 + + - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: "8" # Minecraft 1.16.5 (Forge 1.16.5) se compila con Java 8 + + - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: "17" # ForgeGradle 6 (Forge 1.18.2, 1.19.2 y 1.20.1) pide Java 17 + + # Con las cuatro versiones instaladas (25, 8 y 17 aquí, 21 más arriba; la última, 17, es la predeterminada), el script elige la que pide cada mod + # (Forge 1.18.2, 1.19.2 y 1.20.1: Java 17; NeoForge 1.21.1 y Fabric hasta 1.21.11: Java 21; Fabric 26.1: Java 25; Forge 1.16.5 compila su código con Java 8) + # por las variables JAVA_HOME_8_X64, JAVA_HOME_17_X64, JAVA_HOME_21_X64 y JAVA_HOME_25_X64. + - name: Compilar los mods de apoyo (ModpackES Runtime) con sus pruebas + run: uv run python scripts/construir_mod.py + - name: Construir ModpackES.exe run: uv run python scripts/construir_exe.py diff --git a/.gitignore b/.gitignore index b142568..a55e258 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ build_exe.log # Prototipo antiguo que quedó en la carpeta del proyecto (no forma parte de la app) /traducir_mods.py /TraductorModpacks/ + +# Herramientas de compilación del mod de apoyo (JDK, Gradle, caché de Forge) y su jar compilado +.herramientas/ +src/traductor_es/recursos/mods/*.jar diff --git a/CHANGELOG.md b/CHANGELOG.md index 710ce27..bcb8c19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,41 @@ Todos los cambios importantes de ModpackES quedan en este archivo. El formato si ## [Sin publicar] +### Agregado + +- **Texto escrito en el código de los mods** (opcional, apagado por defecto): la app traduce las frases que encuentra en el código Java de los + mods y las guarda en una tabla del resource pack; el nuevo mod de apoyo **ModpackES Runtime** (solo cliente, para Forge 1.16.5, 1.18.2, 1.19.2 y 1.20.1, NeoForge 1.20.1 y de 1.21 a 1.21.11, y Fabric 1.19.4, de 1.20 a 1.20.6, de 1.21 a 1.21.11 y de 26.1 a 26.3, y Quilt con los mods de Fabric hasta 1.21.1) las muestra en + español en el chat, la barra de acción, los tooltips y el texto de los botones de una pantalla al abrirla (en Forge y NeoForge también los tooltips que no son de objetos). No cambia el texto que una pantalla dibuja directamente. Solo cambia textos idénticos a los de la tabla. «Más ▸ Instalar el mod de apoyo» y + «Quitar el mod de apoyo», `modpackes instalar-runtime` y `--java` en `analizar` y `traducir`. El CI compila el mod con sus pruebas. + +- **Mensajes de chat y datos de texto**, que antes quedaban en inglés: + - El texto de `tellraw`, `title`, `say`, `msg`, `tell` y `w` en las funciones (`.mcfunction`) y en lo que un script de + KubeJS manda con `runCommand` o `runCommandSilent`. + - Títulos y descripciones de logros, y los consejos de las pantallas de carga (mod Tips, en sus dos formatos). + - Mensajes que un script arma por partes (`mensaje += efecto.name; player.tell(mensaje)`), con sus tablas de nombres. + - `notify`, `kick`, `openChestGUI` y `openInventoryGUI` de KubeJS (métodos tomados del código de KubeJS 1.20 y 1.21). +- **Plantillas en el mod de apoyo:** una frase con datos variables escrita con `%s` o `%d` (p. ej. «Hello %s, welcome back!») se traduce y el mod la reconoce con cualquier dato («Hello Steve, welcome back!»). Se descartan las traducciones que cambian el orden de los datos o pierden alguno, y las plantillas con casi nada de texto fijo. +- **Informe «Qué queda en inglés»:** ahora se ve en un cuadro con las categorías separadas y se puede guardar (`Más ▸ Guardar el informe…`); `modpackes cobertura --json` lo escribe como JSON para otros programas. `Más ▸ Comprobar el mod de apoyo` dice si el mod está en `mods/`, si esta copia lo trae y si ya hay tabla de textos. +- **Modo exhaustivo** para los scripts de KubeJS (opcional, apagado por defecto; `--exhaustivo` y una casilla en «Opciones avanzadas»): traduce también toda frase que parezca texto aunque no se sepa dónde se muestra. Puede tocar lógica; todo queda con copia de seguridad. Nunca toca `console.log` ni comparaciones. +- **Funciones propias de un script que solo muestran texto** (`CreateStringTooltip(evento, objeto, texto)`, `crear(id, nombre)`…): se traduce lo que se les pasa en la posición de ese parámetro. Es conservador: si el parámetro se compara, se busca, se guarda, se devuelve, va en un id o en un comando, o la función se usa como valor o está repetida, no se toca nada. Un `id` junto a un `nombre` queda intacto. En un modpack real (TaidumCraft) pasó de 101 a 35 frases sin elegir. +- Los métodos de texto de KubeJS ahora dependen de la versión (`notify`, `openChestGUI`… solo donde existen), comprobados en el código de KubeJS de 1.12 a 26.x (`formatos/kubejs_api.py`). +- Mercado de Farming for Blockheads (`farmingforblockheads_compat`): se traducen los nombres de las pestañas. +- El informe «Qué queda en inglés» también avisa de carpetas de datos con frases en inglés cuyo formato la app no conoce. +- El informe también avisa de los comandos de otros mods (como `emberstextapi queue` o `/eta queue`) que llevan una frase en inglés dentro de un script: la app no conoce su formato y no los toca, pero ya no pasan en silencio. +- Estos datos se buscan por su estructura y no por la carpeta de un modpack: `kubejs/data`, `global_packs`, `datapacks`, + `config/openloader`, los datapacks de un mundo y cualquier otra con `data//functions/…`, sueltos o dentro de un `.zip` + (se reescribe el zip y se guarda el original). Los componentes de texto valen en JSON y en SNBT (1.21.5 y posteriores). +- **Más ▸ Qué queda en inglés** y `modpackes cobertura`: lista lo que no se puede traducir y por qué (frases de scripts cuyo uso + no se entiende y texto escrito en el código de los mods, leído de la tabla de constantes de cada clase). Solo lee. + El análisis avisa de las frases de scripts que parecen texto. +- Pruebas con modpacks públicos de Modrinth (16 más en `scripts/descargar_corpus.py`, de 1.16.5 a 26.x con Forge, NeoForge, Fabric y Quilt) y con la instancia propia: lo traducido + sigue siendo válido, repetir no cambia nada y restaurar devuelve los bytes originales. + +### Cambiado + +- El kit para servidores incluye las funciones y los logros traducidos dondequiera que estén, también los de un datapack comprimido (`.zip`); los `.zip` que solo traen tips siguen fuera porque son de cliente. +- «Instalar el mod de apoyo» copia el mod nuevo antes de borrar los viejos, para no dejarte sin mod si la copia falla, y «Estado del mod de apoyo» avisa cuando el jar instalado es de otra versión de Minecraft en lugar de decir que todo está listo. + ## [2.2.0] - 2026-11-04 Interfaz nueva: una ventana guiada en pasos, con tarjetas y un solo botón principal, y un tema propio diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b7fcbd5..cd5e288 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,12 +78,38 @@ referencia también baja el puntaje, así que revisa los ejemplos que lista el s | `src/traductor_es/nucleo` | Marcadores de códigos, memoria y correcciones, glosario, variantes de español y el orquestador de traducción | | `src/traductor_es/formatos` | Lectura y escritura sin pérdidas: SNBT, JSON, NBT, `.lang`, JavaScript y markdown | | `src/traductor_es/fuentes` | Lanzadores, versión, loader, jars y prioridad de los resource packs | -| `src/traductor_es/modulos` | Misiones, libros, scripts y mods: qué se traduce y dónde se escribe | +| `src/traductor_es/modulos` | Misiones, libros, scripts, datos (funciones, logros, tips) y mods: qué se traduce y dónde se escribe; `cobertura.py` dice qué queda en inglés | | `src/traductor_es/motores` | Gemini, el motor compatible con OpenAI, las instrucciones comunes a todos, la fábrica de motores y el simulador para pruebas | | `src/traductor_es/salida` | Resource pack, `options.txt`, copias de seguridad y exportación | | `src/traductor_es/validacion` | Reglas de calidad, revisión automática, medición con el banco de referencia y el validador con los parsers del juego (Java) | | `src/traductor_es/ui` | La ventana y el editor de correcciones (PySide6) | +## Agregar un formato de texto nuevo + +Los archivos de texto de datapacks y packs de recursos (funciones, logros, tips…) se reconocen por su ruta y su formato, +no por el modpack. Para agregar uno, escribe un `Formato` en `src/traductor_es/modulos/datos.py` (qué ruta lo reconoce, +cómo saca los textos y cómo los reescribe), cita su documentación en un comentario y agrega a `tests/` una prueba que +traduzca un caso real recortado, otra con algo que **no** debe tocarse y la de ida y vuelta (restaurar devuelve los bytes +originales). `uv run python scripts/descargar_corpus.py` baja partes de modpacks públicos de Modrinth a `.descargas/` +(Git lo ignora); `uv run pytest -m corpus` los usa para comprobar que lo traducido sigue siendo válido. +Solo se baja lo que lleva texto (misiones, scripts, `kubejs/data`, `global_packs`, `datapacks`, `config/openloader`, `config/paxi`, +la parte `data/` de `resources/` y los datapacks de cada mundo en `saves/`): si añades una carpeta nueva a `descargar_corpus.py`, +añádela también a `CARPETAS` en `tests/test_corpus_datos.py` y vuelve a bajar los modpacks que ya tenías, que no la traen. +Las 3 pruebas del corpus que se saltan son modpacks sin texto para el jugador (`technical-electrical`, `supernova-empires-2` y +los datapacks de `better-mc-neoforge-bmc5`). + +## El mod de apoyo (Java) + +Cada carpeta de `mod-runtime/` (`forge-1.16.5`, `forge-1.18.2`, `forge-1.19.2`, `forge-1.20.1`, `neoforge-1.21.1`, `neoforge-1.21.4`, `neoforge-1.21.5`, `neoforge-1.21.11`, `fabric-1.19.4`, `fabric-1.20.1`, `fabric-1.20.4`, `fabric-1.20.6`, `fabric-1.21.1`, `fabric-1.21.4`, `fabric-1.21.5`, `fabric-1.21.11`, `fabric-26.1`) es un mod solo de cliente que cambia, al mostrarlos, los textos que otros mods tienen en su código y que están en la tabla de la app. Todos comparten la lógica de tabla y plantillas, que vive en `mod-runtime/comun/` (la tabla es de todos; el reescritor tiene una variante por forma de guardar el texto: `reescritor-literal`, `reescritor-plano` para 1.20.3 en adelante, con `reescritor-plano-1215` solo para sus pruebas desde 1.21.5, donde cambió `ClickEvent`, y `reescritor-sin-reserva` para 1.19.2 `reescritor-texto` para 1.18.2, donde el texto aún es `TextComponent`, y `reescritor-1165` para 1.16.5, con los nombres antiguos de las clases, sin tooltips por elementos y en Java 8; por eso `Tabla` evita todo lo posterior a Java 8), y cada proyecto solo trae su gancho con el loader. Forge 1.16.5, 1.18.2, 1.19.2 y 1.20.1 se compilan con ForgeGradle 6 y Java 17 (el de 1.16.5 compila su código con un JDK 8, que `construir_mod.py` le pasa a Gradle como toolchain); NeoForge 1.21.1, con ModDevGradle, Gradle 9 y Java 21; Fabric, con Loom 1.17, Gradle 9 y Java 21 (los de 1.19.4, 1.20.1 y 1.20.4 quedan compilados para Java 17; los de 1.20.6 en adelante, para Java 21); Fabric 26.1, con Loom 1.19 sin remapeo (Minecraft 26.x ya no está ofuscado) y Java 25: + +``` +uv run python scripts/construir_mod.py [forge-1.16.5 | forge-1.18.2 | forge-1.19.2 | forge-1.20.1 | neoforge-1.21.1 | neoforge-1.21.4 | neoforge-1.21.5 | neoforge-1.21.11 | fabric-1.19.4 | fabric-1.20.1 | fabric-1.20.4 | fabric-1.20.6 | fabric-1.21.1 | fabric-1.21.4 | fabric-1.21.5 | fabric-1.21.11 | fabric-26.1] +``` + +Si Gradle falla por la red (por ejemplo, un «Read timed out» o un HTTP 5xx al bajar su distribución o Minecraft), el script lo reintenta hasta 3 veces; un error del código o de una prueba no se reintenta. El wrapper de cada proyecto espera hasta 120 s en esas descargas (`networkTimeout`). `tests/test_mod_apoyo_coherencia.py` comprueba que la tabla `SOPORTADOS`, los proyectos de `mod-runtime/` y los rangos de versiones que declaran coincidan, sin compilar nada. + +Corre las pruebas de Java (JUnit) y deja el jar en `src/traductor_es/recursos/mods/` (Git lo ignora; el CI lo construye y `construir_exe.py` lo incluye). Un loader o versión nuevo es un proyecto nuevo en `mod-runtime/` (copia el más cercano y lee el código real de esa versión: los nombres de las clases y los eventos cambian), una entrada en `PROYECTOS` de `scripts/construir_mod.py` y otra en `SOPORTADOS` de `salida/mod_apoyo.py`. Un mismo jar puede servir para varias versiones vecinas si el mismo código compila y pasa las pruebas contra cada una: `uv run python scripts/construir_mod.py fabric-1.21.5 --propiedad minecraft_version=1.21.8 --propiedad fabric_api_version=0.136.1+1.21.8 --propiedad loader_version=0.16.13` lo comprueba sin generar jar, y el rango comprobado va en `minecraft_range` del proyecto y en `SOPORTADOS`. Quilt no tiene proyectos propios: carga los mods de Fabric con Quilted Fabric API, y `_VERSIONES_DE_QUILT` lista las versiones donde esa API existe. Si una versión no puede tener mod, se anota en `IMPOSIBLES` con el motivo (Fabric 1.16.5, 1.18.2 y 1.19.2 no tienen evento de chat del cliente). + ## Licencia de las contribuciones ModpackES usa la licencia [GPL-3.0-or-later](LICENSE). Al mandar un cambio aceptas que se publique bajo esa diff --git a/README.md b/README.md index f03d634..dfca95a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,9 @@ y **cualquier lanzador**, y escribe a la vez las siete variantes de español del | Textos de mods (objetos, bloques, menús, tooltips) | `lang/*.json` (1.13+) y `.lang` (1.12 y antes), incluidos los jars anidados, KubeJS y los resource packs del modpack | Resource pack `Traduccion_ES` | | Misiones | FTB Quests (texto en los capítulos, carpeta `lang/` de 1.21+ y NBT de 1.12), BetterQuesting, HQM, Heracles | En su sitio, con copia de seguridad | | Libros guía | Patchouli, Mantle (Tinkers), Citadel (Alex's Mobs), GuideME (AE2), Lavender. Modonomicon va por claves de idioma | Resource pack (o en su sitio, si es un libro propio del modpack) | -| Scripts de KubeJS | Lo que ve el jugador: `tell`, `displayName`, `Text.of`, tooltips, información de JEI/REI/EMI | En su sitio, con copia de seguridad | +| Scripts de KubeJS | Lo que ve el jugador: `tell`, `notify`, `displayName`, `Text.of`, tooltips, información de JEI/REI/EMI, los mensajes armados por partes antes de un `tell`, y el texto de `tellraw`, `title` y `say` en `runCommand` | En su sitio, con copia de seguridad | +| Datapacks y tips | Funciones (`.mcfunction`: `tellraw`, `title`, `say`), títulos y descripciones de logros, y consejos de pantalla de carga (mod Tips). Se buscan por su estructura (`data//functions/…`), estén en `kubejs/data`, `global_packs`, `datapacks`, `config/openloader` o en los datapacks de un mundo, sueltos o dentro de un `.zip`. Los componentes de texto valen en JSON (hasta 1.21.4) y en SNBT (desde 1.21.5) | En su sitio, con copia de seguridad | +| Texto escrito en el código de los mods | Mensajes de chat, de la barra de acción, tooltips y texto de botones que un mod tiene en su código Java. Un resource pack no los alcanza: la app los traduce a una tabla que lee el mod de apoyo **ModpackES Runtime** (solo cliente, Forge 1.16.5, 1.18.2, 1.19.2 y 1.20.1, NeoForge 1.20.1 y de 1.21 a 1.21.11, y Fabric 1.19.4, de 1.20 a 1.20.6, de 1.21 a 1.21.11 y de 26.1 a 26.3) | Tabla en el resource pack; el mod se instala solo si lo pides | Lo que el mod ya trae en español no se toca, y si existe una variante (por ejemplo `es_es`) se copia a las demás sin gastar. Los libros antiguos de Patchouli que guardan el texto dentro del jar no se pueden traducir @@ -84,6 +86,12 @@ traducir indicando el error. La nueva traducción tiene que pasar las mismas reg Tus correcciones nunca se revisan. Sube el costo de esos textos (el análisis lo incluye) y sus marcas no son infalibles: sirven para decidir qué rehacer, no como nota de calidad. +**Texto del código de los mods** (opcional): activa «Incluir texto escrito en el código de los mods» en «Opciones avanzadas» y pulsa **Más ▸ Instalar el mod de apoyo**. La app traduce las frases que encuentra en el código de los mods y el mod de apoyo las muestra en español en el chat, la barra de acción y los tooltips. Solo cambia un texto si es **idéntico** al de la tabla (no hay traducción en vivo ni en internet), reconoce frases con datos escritos con `%s` o `%d` como plantillas, cambia también el texto de los botones de una pantalla al abrirla y, en Forge y NeoForge, los tooltips que no son de objetos; el texto que una pantalla dibuja directamente no se puede cambiar sin Mixin y no se toca; por ahora existe para Forge 1.16.5, 1.18.2, 1.19.2 y 1.20.1, NeoForge 1.20.1 y de 1.21 a 1.21.11, y Fabric 1.19.4, de 1.20 a 1.20.6, de 1.21 a 1.21.11 y de 26.1 a 26.3. Quilt usa los mods de Fabric con Quilted Fabric API (hasta 1.21.1) y no se ha probado en Quilt. Para Fabric 1.16.5, 1.18.2 y 1.19.2 no puede existir: Fabric API no tiene un evento de chat del cliente en esas versiones, y la app lo dice. **Más ▸ Quitar el mod de apoyo** lo retira de la carpeta mods; en la línea de comandos: `modpackes instalar-runtime RUTA [--quitar]` y `--java` en `analizar` y `traducir`. Para comprobarlo en el juego, con el mod instalado y el juego en español, escribe `/tellraw @s "TPA request expired!"` (o cualquier frase de la tabla). + +**Modo exhaustivo de scripts** (opcional, en «Opciones avanzadas» o con `--exhaustivo`): traduce también las frases de los scripts de KubeJS cuyo uso no se entiende (por ejemplo textos que pasan por una función propia del pack). Puede tocar lógica del script, por eso viene apagado; todo queda con copia de seguridad y se restaura con «Restaurar el original». + +**Más ▸ Qué queda en inglés** (o `modpackes cobertura RUTA [--java]`) lista lo que no se traduce y por qué, sin cambiar nada: frases de scripts cuyo uso no se entiende, frases dentro de comandos de otros mods (p. ej. `emberstextapi queue`), cuyo formato la app no conoce y por eso no toca, y texto escrito en el código de los mods, que un resource pack no puede alcanzar. El análisis ya avisa de las frases de scripts. + **Más ▸ Restaurar el original** devuelve misiones, scripts y libros propios al texto del modpack. Para quitar la traducción de los mods basta con desactivar el resource pack. @@ -182,6 +190,7 @@ uv run modpackes instancias uv run modpackes analizar "RUTA DE LA INSTANCIA" uv run modpackes traducir "RUTA" [--dialecto es_es] [--solo misiones,libros] [--lotes] [--modelo gemini-3.5-flash-lite] [--contexto "granja acogedora"] [--revisar] uv run modpackes restaurar "RUTA" +uv run modpackes cobertura "RUTA" [--java] # qué queda en inglés y por qué (solo lee) uv run modpackes clave guardar [--motor openrouter] uv run modpackes modelos [--motor ollama] ``` diff --git a/mod-runtime/comun/reescritor-1165/src/main/java/io/github/consterna/modpackes/Reescritor.java b/mod-runtime/comun/reescritor-1165/src/main/java/io/github/consterna/modpackes/Reescritor.java new file mode 100644 index 0000000..aac7a6f --- /dev/null +++ b/mod-runtime/comun/reescritor-1165/src/main/java/io/github/consterna/modpackes/Reescritor.java @@ -0,0 +1,96 @@ +package io.github.consterna.modpackes; + + +import net.minecraft.client.gui.widget.Widget; +import net.minecraft.util.text.IFormattableTextComponent; +import net.minecraft.util.text.ITextComponent; +import net.minecraft.util.text.StringTextComponent; +import net.minecraft.util.text.TranslationTextComponent; + +import java.util.ArrayList; +import java.util.List; + +/** + * Cambia el texto literal de un componente por su traducción, sin tocar estilos, colores, eventos de clic ni + * el resto de la estructura. Nunca lanza por un componente raro: si algo no se entiende, se deja igual. + * Variante de Minecraft 1.16.5: Java 8, el texto es un componente propio (StringTextComponent, TranslationTextComponent) + * y no hay tooltips por elementos. + */ +public final class Reescritor { + private Reescritor() { + } + + /** El componente traducido, o {@code null} si no cambió nada (así no se copia lo que no hace falta). */ + public static ITextComponent reescribir(ITextComponent original, Tabla tabla) { + if (original == null || tabla.vacia()) { + return null; + } + String textoNuevo = null; + Object[] argumentosNuevos = null; + boolean cambio = false; + + if (original instanceof StringTextComponent) { + textoNuevo = tabla.traducir(((StringTextComponent) original).getText()); + cambio = textoNuevo != null; + } else if (original instanceof TranslationTextComponent) { + Object[] argumentos = ((TranslationTextComponent) original).getArgs(); + for (int i = 0; i < argumentos.length; i++) { + Object reemplazo = null; + if (argumentos[i] instanceof ITextComponent) { + reemplazo = reescribir((ITextComponent) argumentos[i], tabla); + } else if (argumentos[i] instanceof String) { + reemplazo = tabla.traducir((String) argumentos[i]); + } + if (reemplazo != null) { + if (argumentosNuevos == null) { + argumentosNuevos = argumentos.clone(); + } + argumentosNuevos[i] = reemplazo; + } + } + cambio = argumentosNuevos != null; + } + + List hermanos = original.getSiblings(); + List nuevosHermanos = null; + for (int i = 0; i < hermanos.size(); i++) { + ITextComponent reemplazo = reescribir(hermanos.get(i), tabla); + if (reemplazo != null) { + if (nuevosHermanos == null) { + nuevosHermanos = new ArrayList<>(hermanos); + } + nuevosHermanos.set(i, reemplazo); + } + } + + if (!cambio && nuevosHermanos == null) { + return null; + } + IFormattableTextComponent resultado; + if (original instanceof StringTextComponent) { + resultado = new StringTextComponent(textoNuevo != null ? textoNuevo : ((StringTextComponent) original).getText()); + } else if (original instanceof TranslationTextComponent) { + TranslationTextComponent traducible = (TranslationTextComponent) original; + resultado = new TranslationTextComponent( + traducible.getKey(), argumentosNuevos != null ? argumentosNuevos : traducible.getArgs()); + } else { + resultado = original.plainCopy(); // otro tipo de componente: se copia tal cual y solo cambian sus hermanos + } + resultado.setStyle(original.getStyle()); + for (ITextComponent hermano : nuevosHermanos != null ? nuevosHermanos : hermanos) { + resultado.append(hermano); + } + return resultado; + } + + /** Cambia el texto de un botón, casilla o etiqueta si está en la tabla. */ + public static void reescribirMensaje(Widget widget, Tabla tabla) { + if (widget == null || tabla.vacia()) { + return; + } + ITextComponent nuevo = reescribir(widget.getMessage(), tabla); + if (nuevo != null) { + widget.setMessage(nuevo); + } + } +} diff --git a/mod-runtime/comun/reescritor-1165/src/test/java/io/github/consterna/modpackes/ReescritorTest.java b/mod-runtime/comun/reescritor-1165/src/test/java/io/github/consterna/modpackes/ReescritorTest.java new file mode 100644 index 0000000..2fe3ec7 --- /dev/null +++ b/mod-runtime/comun/reescritor-1165/src/test/java/io/github/consterna/modpackes/ReescritorTest.java @@ -0,0 +1,86 @@ +package io.github.consterna.modpackes; + +import net.minecraft.util.text.TextFormatting; +import net.minecraft.util.text.event.ClickEvent; +import net.minecraft.util.text.ITextComponent; +import net.minecraft.util.text.IFormattableTextComponent; +import net.minecraft.util.text.Style; +import net.minecraft.util.text.StringTextComponent; +import net.minecraft.util.text.TranslationTextComponent; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ReescritorTest { + private static Tabla tabla(String... pares) { // Map.of no existe en Java 8 + Map mapa = new HashMap<>(); + for (int i = 0; i < pares.length; i += 2) { + mapa.put(pares[i], pares[i + 1]); + } + return new Tabla(mapa); + } + + private static final Tabla TABLA = tabla( + "TPA request expired!", "¡La solicitud de TPA expiró!", + "Hello", "Hola", + "Click here", "Haz clic aquí"); + + @Test + void cambiaUnLiteralYConservaElEstilo() { + Style estilo = Style.EMPTY.withColor(TextFormatting.RED).withBold(true); + ITextComponent original = new StringTextComponent("TPA request expired!").setStyle(estilo); + ITextComponent nuevo = Reescritor.reescribir(original, TABLA); + assertEquals("¡La solicitud de TPA expiró!", nuevo.getString()); + assertEquals(estilo, nuevo.getStyle()); + } + + @Test + void sinCoincidenciaDevuelveNullYNoCopia() { + assertNull(Reescritor.reescribir(new StringTextComponent("Nothing to see here"), TABLA)); + assertNull(Reescritor.reescribir(new StringTextComponent("Hello"), Tabla.VACIA)); + assertNull(Reescritor.reescribir(null, TABLA)); + } + + @Test + void recorreLosHermanosYSoloCambiaLosQueCoinciden() { + IFormattableTextComponent raiz = new StringTextComponent(" ").withStyle(TextFormatting.AQUA); + raiz.append(new StringTextComponent("Hello").withStyle(TextFormatting.WHITE)); + raiz.append(new StringTextComponent(" Steve")); + ITextComponent nuevo = Reescritor.reescribir(raiz, TABLA); + assertEquals(" Hola Steve", nuevo.getString()); + assertEquals(TextFormatting.AQUA.getColor(), nuevo.getStyle().getColor().getValue()); + assertEquals(TextFormatting.WHITE.getColor(), nuevo.getSiblings().get(0).getStyle().getColor().getValue()); + assertSame(raiz.getSiblings().get(1), nuevo.getSiblings().get(1)); // lo que no cambia, no se copia + } + + @Test + void conservaElEventoDeClic() { + Style estilo = Style.EMPTY.withClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, "https://example.com")); + ITextComponent nuevo = Reescritor.reescribir(new StringTextComponent("Click here").setStyle(estilo), TABLA); + assertEquals("Haz clic aquí", nuevo.getString()); + assertEquals("https://example.com", nuevo.getStyle().getClickEvent().getValue()); + } + + @Test + void cambiaLosArgumentosDeUnTraducible() { + ITextComponent original = new TranslationTextComponent("chat.type.text", new StringTextComponent("Hello"), "Hello"); + ITextComponent nuevo = Reescritor.reescribir(original, TABLA); + TranslationTextComponent contenido = (TranslationTextComponent) nuevo; + assertEquals("chat.type.text", contenido.getKey()); + assertEquals("Hola", ((ITextComponent) contenido.getArgs()[0]).getString()); + assertEquals("Hola", contenido.getArgs()[1]); + } + + @Test + void noInventaTextoConEspaciosYAcentos() { + Tabla t = tabla("Ñandú says hi", "Ñandú dice ¡hola! 😀"); + ITextComponent nuevo = Reescritor.reescribir(new StringTextComponent(" Ñandú says hi "), t); + assertTrue(nuevo.getString().equals(" Ñandú dice ¡hola! 😀 ")); + } +} diff --git a/mod-runtime/comun/reescritor-literal/src/main/java/io/github/consterna/modpackes/Reescritor.java b/mod-runtime/comun/reescritor-literal/src/main/java/io/github/consterna/modpackes/Reescritor.java new file mode 100644 index 0000000..28b7267 --- /dev/null +++ b/mod-runtime/comun/reescritor-literal/src/main/java/io/github/consterna/modpackes/Reescritor.java @@ -0,0 +1,113 @@ +package io.github.consterna.modpackes; + +import com.mojang.datafixers.util.Either; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.ComponentContents; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.contents.LiteralContents; +import net.minecraft.network.chat.contents.TranslatableContents; +import net.minecraft.world.inventory.tooltip.TooltipComponent; + +import java.util.ArrayList; +import java.util.List; + +/** + * Cambia el texto literal de un componente por su traducción, sin tocar estilos, colores, eventos de clic ni + * el resto de la estructura. Nunca lanza por un componente raro: si algo no se entiende, se deja igual. + */ +public final class Reescritor { + private Reescritor() { + } + + /** El componente traducido, o {@code null} si no cambió nada (así no se copia lo que no hace falta). */ + public static Component reescribir(Component original, Tabla tabla) { + if (original == null || tabla.vacia()) { + return null; + } + ComponentContents contenido = original.getContents(); + ComponentContents nuevoContenido = contenido; + boolean cambio = false; + + if (contenido instanceof LiteralContents literal) { + String traducido = tabla.traducir(literal.text()); + if (traducido != null) { + nuevoContenido = new LiteralContents(traducido); + cambio = true; + } + } else if (contenido instanceof TranslatableContents traducible) { + Object[] argumentos = traducible.getArgs(); + Object[] nuevos = null; + for (int i = 0; i < argumentos.length; i++) { + Object reemplazo = null; + if (argumentos[i] instanceof Component hijo) { + reemplazo = reescribir(hijo, tabla); + } else if (argumentos[i] instanceof String texto) { + reemplazo = tabla.traducir(texto); + } + if (reemplazo != null) { + if (nuevos == null) { + nuevos = argumentos.clone(); + } + nuevos[i] = reemplazo; + } + } + if (nuevos != null) { + nuevoContenido = new TranslatableContents(traducible.getKey(), traducible.getFallback(), nuevos); + cambio = true; + } + } + + List hermanos = original.getSiblings(); + List nuevosHermanos = null; + for (int i = 0; i < hermanos.size(); i++) { + Component reemplazo = reescribir(hermanos.get(i), tabla); + if (reemplazo != null) { + if (nuevosHermanos == null) { + nuevosHermanos = new ArrayList<>(hermanos); + } + nuevosHermanos.set(i, reemplazo); + } + } + + if (!cambio && nuevosHermanos == null) { + return null; + } + MutableComponent resultado = MutableComponent.create(nuevoContenido).setStyle(original.getStyle()); + for (Component hermano : nuevosHermanos != null ? nuevosHermanos : hermanos) { + resultado.append(hermano); + } + return resultado; + } + + /** + * Cambia en su sitio las líneas de texto de un tooltip (las que son un {@link Component}); lo que no es texto + * o no está en la tabla se deja como está. + */ + public static void reescribirElementos(List> elementos, Tabla tabla) { + if (elementos == null || tabla.vacia()) { + return; + } + for (int i = 0; i < elementos.size(); i++) { + Either elemento = elementos.get(i); + if (elemento != null && elemento.left().orElse(null) instanceof Component linea) { + Component nuevo = reescribir(linea, tabla); + if (nuevo != null) { + elementos.set(i, Either.left(nuevo)); + } + } + } + } + + /** Cambia el texto de un botón, casilla o etiqueta si está en la tabla. */ + public static void reescribirMensaje(AbstractWidget widget, Tabla tabla) { + if (widget == null || tabla.vacia()) { + return; + } + Component nuevo = reescribir(widget.getMessage(), tabla); + if (nuevo != null) { + widget.setMessage(nuevo); + } + } +} diff --git a/mod-runtime/comun/reescritor-literal/src/test/java/io/github/consterna/modpackes/ReescritorTest.java b/mod-runtime/comun/reescritor-literal/src/test/java/io/github/consterna/modpackes/ReescritorTest.java new file mode 100644 index 0000000..592ea54 --- /dev/null +++ b/mod-runtime/comun/reescritor-literal/src/test/java/io/github/consterna/modpackes/ReescritorTest.java @@ -0,0 +1,113 @@ +package io.github.consterna.modpackes; + +import com.mojang.datafixers.util.Either; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.contents.TranslatableContents; +import net.minecraft.world.inventory.tooltip.TooltipComponent; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ReescritorTest { + private static final Tabla TABLA = new Tabla(Map.of( + "TPA request expired!", "¡La solicitud de TPA expiró!", + "Hello", "Hola", + "Click here", "Haz clic aquí")); + + @Test + void cambiaUnLiteralYConservaElEstilo() { + Style estilo = Style.EMPTY.withColor(ChatFormatting.RED).withBold(true); + Component original = Component.literal("TPA request expired!").setStyle(estilo); + Component nuevo = Reescritor.reescribir(original, TABLA); + assertEquals("¡La solicitud de TPA expiró!", nuevo.getString()); + assertEquals(estilo, nuevo.getStyle()); + } + + @Test + void sinCoincidenciaDevuelveNullYNoCopia() { + assertNull(Reescritor.reescribir(Component.literal("Nothing to see here"), TABLA)); + assertNull(Reescritor.reescribir(Component.literal("Hello"), Tabla.VACIA)); + assertNull(Reescritor.reescribir(null, TABLA)); + } + + @Test + void recorreLosHermanosYSoloCambiaLosQueCoinciden() { + MutableComponent raiz = Component.literal(" ").withStyle(ChatFormatting.AQUA); + raiz.append(Component.literal("Hello").withStyle(ChatFormatting.WHITE)); + raiz.append(Component.literal(" Steve")); + Component nuevo = Reescritor.reescribir(raiz, TABLA); + assertEquals(" Hola Steve", nuevo.getString()); + assertEquals(ChatFormatting.AQUA.getColor(), nuevo.getStyle().getColor().getValue()); + assertEquals(ChatFormatting.WHITE.getColor(), nuevo.getSiblings().get(0).getStyle().getColor().getValue()); + assertSame(raiz.getSiblings().get(1), nuevo.getSiblings().get(1)); // lo que no cambia, no se copia + } + + @Test + void conservaElEventoDeClic() { + Style estilo = Style.EMPTY.withClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, "https://example.com")); + Component nuevo = Reescritor.reescribir(Component.literal("Click here").setStyle(estilo), TABLA); + assertEquals("Haz clic aquí", nuevo.getString()); + assertEquals("https://example.com", nuevo.getStyle().getClickEvent().getValue()); + } + + @Test + void cambiaLosArgumentosDeUnTraducible() { + Component original = Component.translatable("chat.type.text", Component.literal("Hello"), "Hello"); + Component nuevo = Reescritor.reescribir(original, TABLA); + TranslatableContents contenido = (TranslatableContents) nuevo.getContents(); + assertEquals("chat.type.text", contenido.getKey()); + assertEquals("Hola", ((Component) contenido.getArgs()[0]).getString()); + assertEquals("Hola", contenido.getArgs()[1]); + } + + @Test + void noInventaTextoConEspaciosYAcentos() { + Tabla t = new Tabla(Map.of("Ñandú says hi", "Ñandú dice ¡hola! 😀")); + Component nuevo = Reescritor.reescribir(Component.literal(" Ñandú says hi "), t); + assertTrue(nuevo.getString().equals(" Ñandú dice ¡hola! 😀 ")); + } + + @Test + void cambiaLasLineasDeUnTooltipYDejaLoQueNoEsTexto() { + TooltipComponent imagen = new TooltipComponent() { + }; + FormattedText plano = FormattedText.of("Hello"); + Component otra = Component.literal("Other"); + List> elementos = new ArrayList<>(); + elementos.add(Either.left(Component.literal("Hello"))); + elementos.add(Either.right(imagen)); + elementos.add(Either.left(plano)); + elementos.add(Either.left(otra)); + + Reescritor.reescribirElementos(elementos, TABLA); + + assertEquals("Hola", ((Component) elementos.get(0).left().orElseThrow()).getString()); + assertSame(imagen, elementos.get(1).right().orElseThrow()); + assertSame(plano, elementos.get(2).left().orElseThrow()); + assertSame(otra, elementos.get(3).left().orElseThrow()); + } + + @Test + void losTooltipsSinTablaOSinListaNoHacenNada() { + Component original = Component.literal("Hello"); + List> elementos = new ArrayList<>(); + elementos.add(Either.left(original)); + + Reescritor.reescribirElementos(elementos, Tabla.VACIA); + Reescritor.reescribirElementos(null, TABLA); + + assertSame(original, elementos.get(0).left().orElseThrow()); + } +} diff --git a/mod-runtime/comun/reescritor-plano-1215/src/test/java/io/github/consterna/modpackes/ReescritorTest.java b/mod-runtime/comun/reescritor-plano-1215/src/test/java/io/github/consterna/modpackes/ReescritorTest.java new file mode 100644 index 0000000..eee0890 --- /dev/null +++ b/mod-runtime/comun/reescritor-plano-1215/src/test/java/io/github/consterna/modpackes/ReescritorTest.java @@ -0,0 +1,115 @@ +package io.github.consterna.modpackes; + +import com.mojang.datafixers.util.Either; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.contents.TranslatableContents; +import net.minecraft.world.inventory.tooltip.TooltipComponent; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ReescritorTest { + private static final Tabla TABLA = new Tabla(Map.of( + "TPA request expired!", "¡La solicitud de TPA expiró!", + "Hello", "Hola", + "Click here", "Haz clic aquí")); + + @Test + void cambiaUnLiteralYConservaElEstilo() { + Style estilo = Style.EMPTY.withColor(ChatFormatting.RED).withBold(true); + Component original = Component.literal("TPA request expired!").setStyle(estilo); + Component nuevo = Reescritor.reescribir(original, TABLA); + assertEquals("¡La solicitud de TPA expiró!", nuevo.getString()); + assertEquals(estilo, nuevo.getStyle()); + } + + @Test + void sinCoincidenciaDevuelveNullYNoCopia() { + assertNull(Reescritor.reescribir(Component.literal("Nothing to see here"), TABLA)); + assertNull(Reescritor.reescribir(Component.literal("Hello"), Tabla.VACIA)); + assertNull(Reescritor.reescribir(null, TABLA)); + } + + @Test + void recorreLosHermanosYSoloCambiaLosQueCoinciden() { + MutableComponent raiz = Component.literal(" ").withStyle(ChatFormatting.AQUA); + raiz.append(Component.literal("Hello").withStyle(ChatFormatting.WHITE)); + raiz.append(Component.literal(" Steve")); + Component nuevo = Reescritor.reescribir(raiz, TABLA); + assertEquals(" Hola Steve", nuevo.getString()); + // los colores se comparan con los del original: ChatFormatting.getColor() desaparece en Minecraft 26.2 + assertEquals(raiz.getStyle().getColor(), nuevo.getStyle().getColor()); + assertEquals(raiz.getSiblings().get(0).getStyle().getColor(), nuevo.getSiblings().get(0).getStyle().getColor()); + assertSame(raiz.getSiblings().get(1), nuevo.getSiblings().get(1)); // lo que no cambia, no se copia + } + + @Test + void conservaElEventoDeClic() { + Style estilo = Style.EMPTY.withClickEvent(new ClickEvent.OpenUrl(URI.create("https://example.com"))); + Component nuevo = Reescritor.reescribir(Component.literal("Click here").setStyle(estilo), TABLA); + assertEquals("Haz clic aquí", nuevo.getString()); + assertEquals(estilo.getClickEvent(), nuevo.getStyle().getClickEvent()); + } + + @Test + void cambiaLosArgumentosDeUnTraducible() { + Component original = Component.translatable("chat.type.text", Component.literal("Hello"), "Hello"); + Component nuevo = Reescritor.reescribir(original, TABLA); + TranslatableContents contenido = (TranslatableContents) nuevo.getContents(); + assertEquals("chat.type.text", contenido.getKey()); + assertEquals("Hola", ((Component) contenido.getArgs()[0]).getString()); + assertEquals("Hola", contenido.getArgs()[1]); + } + + @Test + void noInventaTextoConEspaciosYAcentos() { + Tabla t = new Tabla(Map.of("Ñandú says hi", "Ñandú dice ¡hola! 😀")); + Component nuevo = Reescritor.reescribir(Component.literal(" Ñandú says hi "), t); + assertTrue(nuevo.getString().equals(" Ñandú dice ¡hola! 😀 ")); + } + + @Test + void cambiaLasLineasDeUnTooltipYDejaLoQueNoEsTexto() { + TooltipComponent imagen = new TooltipComponent() { + }; + FormattedText plano = FormattedText.of("Hello"); + Component otra = Component.literal("Other"); + List> elementos = new ArrayList<>(); + elementos.add(Either.left(Component.literal("Hello"))); + elementos.add(Either.right(imagen)); + elementos.add(Either.left(plano)); + elementos.add(Either.left(otra)); + + Reescritor.reescribirElementos(elementos, TABLA); + + assertEquals("Hola", ((Component) elementos.get(0).left().orElseThrow()).getString()); + assertSame(imagen, elementos.get(1).right().orElseThrow()); + assertSame(plano, elementos.get(2).left().orElseThrow()); + assertSame(otra, elementos.get(3).left().orElseThrow()); + } + + @Test + void losTooltipsSinTablaOSinListaNoHacenNada() { + Component original = Component.literal("Hello"); + List> elementos = new ArrayList<>(); + elementos.add(Either.left(original)); + + Reescritor.reescribirElementos(elementos, Tabla.VACIA); + Reescritor.reescribirElementos(null, TABLA); + + assertSame(original, elementos.get(0).left().orElseThrow()); + } +} diff --git a/mod-runtime/comun/reescritor-plano/src/main/java/io/github/consterna/modpackes/Reescritor.java b/mod-runtime/comun/reescritor-plano/src/main/java/io/github/consterna/modpackes/Reescritor.java new file mode 100644 index 0000000..60b82e6 --- /dev/null +++ b/mod-runtime/comun/reescritor-plano/src/main/java/io/github/consterna/modpackes/Reescritor.java @@ -0,0 +1,113 @@ +package io.github.consterna.modpackes; + +import com.mojang.datafixers.util.Either; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.ComponentContents; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.contents.PlainTextContents; +import net.minecraft.network.chat.contents.TranslatableContents; +import net.minecraft.world.inventory.tooltip.TooltipComponent; + +import java.util.ArrayList; +import java.util.List; + +/** + * Cambia el texto literal de un componente por su traducción, sin tocar estilos, colores, eventos de clic ni + * el resto de la estructura. Nunca lanza por un componente raro: si algo no se entiende, se deja igual. + */ +public final class Reescritor { + private Reescritor() { + } + + /** El componente traducido, o {@code null} si no cambió nada (así no se copia lo que no hace falta). */ + public static Component reescribir(Component original, Tabla tabla) { + if (original == null || tabla.vacia()) { + return null; + } + ComponentContents contenido = original.getContents(); + ComponentContents nuevoContenido = contenido; + boolean cambio = false; + + if (contenido instanceof PlainTextContents.LiteralContents literal) { + String traducido = tabla.traducir(literal.text()); + if (traducido != null) { + nuevoContenido = new PlainTextContents.LiteralContents(traducido); + cambio = true; + } + } else if (contenido instanceof TranslatableContents traducible) { + Object[] argumentos = traducible.getArgs(); + Object[] nuevos = null; + for (int i = 0; i < argumentos.length; i++) { + Object reemplazo = null; + if (argumentos[i] instanceof Component hijo) { + reemplazo = reescribir(hijo, tabla); + } else if (argumentos[i] instanceof String texto) { + reemplazo = tabla.traducir(texto); + } + if (reemplazo != null) { + if (nuevos == null) { + nuevos = argumentos.clone(); + } + nuevos[i] = reemplazo; + } + } + if (nuevos != null) { + nuevoContenido = new TranslatableContents(traducible.getKey(), traducible.getFallback(), nuevos); + cambio = true; + } + } + + List hermanos = original.getSiblings(); + List nuevosHermanos = null; + for (int i = 0; i < hermanos.size(); i++) { + Component reemplazo = reescribir(hermanos.get(i), tabla); + if (reemplazo != null) { + if (nuevosHermanos == null) { + nuevosHermanos = new ArrayList<>(hermanos); + } + nuevosHermanos.set(i, reemplazo); + } + } + + if (!cambio && nuevosHermanos == null) { + return null; + } + MutableComponent resultado = MutableComponent.create(nuevoContenido).setStyle(original.getStyle()); + for (Component hermano : nuevosHermanos != null ? nuevosHermanos : hermanos) { + resultado.append(hermano); + } + return resultado; + } + + /** + * Cambia en su sitio las líneas de texto de un tooltip (las que son un {@link Component}); lo que no es texto + * o no está en la tabla se deja como está. + */ + public static void reescribirElementos(List> elementos, Tabla tabla) { + if (elementos == null || tabla.vacia()) { + return; + } + for (int i = 0; i < elementos.size(); i++) { + Either elemento = elementos.get(i); + if (elemento != null && elemento.left().orElse(null) instanceof Component linea) { + Component nuevo = reescribir(linea, tabla); + if (nuevo != null) { + elementos.set(i, Either.left(nuevo)); + } + } + } + } + + /** Cambia el texto de un botón, casilla o etiqueta si está en la tabla. */ + public static void reescribirMensaje(AbstractWidget widget, Tabla tabla) { + if (widget == null || tabla.vacia()) { + return; + } + Component nuevo = reescribir(widget.getMessage(), tabla); + if (nuevo != null) { + widget.setMessage(nuevo); + } + } +} diff --git a/mod-runtime/comun/reescritor-plano/src/test/java/io/github/consterna/modpackes/ReescritorTest.java b/mod-runtime/comun/reescritor-plano/src/test/java/io/github/consterna/modpackes/ReescritorTest.java new file mode 100644 index 0000000..592ea54 --- /dev/null +++ b/mod-runtime/comun/reescritor-plano/src/test/java/io/github/consterna/modpackes/ReescritorTest.java @@ -0,0 +1,113 @@ +package io.github.consterna.modpackes; + +import com.mojang.datafixers.util.Either; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.contents.TranslatableContents; +import net.minecraft.world.inventory.tooltip.TooltipComponent; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ReescritorTest { + private static final Tabla TABLA = new Tabla(Map.of( + "TPA request expired!", "¡La solicitud de TPA expiró!", + "Hello", "Hola", + "Click here", "Haz clic aquí")); + + @Test + void cambiaUnLiteralYConservaElEstilo() { + Style estilo = Style.EMPTY.withColor(ChatFormatting.RED).withBold(true); + Component original = Component.literal("TPA request expired!").setStyle(estilo); + Component nuevo = Reescritor.reescribir(original, TABLA); + assertEquals("¡La solicitud de TPA expiró!", nuevo.getString()); + assertEquals(estilo, nuevo.getStyle()); + } + + @Test + void sinCoincidenciaDevuelveNullYNoCopia() { + assertNull(Reescritor.reescribir(Component.literal("Nothing to see here"), TABLA)); + assertNull(Reescritor.reescribir(Component.literal("Hello"), Tabla.VACIA)); + assertNull(Reescritor.reescribir(null, TABLA)); + } + + @Test + void recorreLosHermanosYSoloCambiaLosQueCoinciden() { + MutableComponent raiz = Component.literal(" ").withStyle(ChatFormatting.AQUA); + raiz.append(Component.literal("Hello").withStyle(ChatFormatting.WHITE)); + raiz.append(Component.literal(" Steve")); + Component nuevo = Reescritor.reescribir(raiz, TABLA); + assertEquals(" Hola Steve", nuevo.getString()); + assertEquals(ChatFormatting.AQUA.getColor(), nuevo.getStyle().getColor().getValue()); + assertEquals(ChatFormatting.WHITE.getColor(), nuevo.getSiblings().get(0).getStyle().getColor().getValue()); + assertSame(raiz.getSiblings().get(1), nuevo.getSiblings().get(1)); // lo que no cambia, no se copia + } + + @Test + void conservaElEventoDeClic() { + Style estilo = Style.EMPTY.withClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, "https://example.com")); + Component nuevo = Reescritor.reescribir(Component.literal("Click here").setStyle(estilo), TABLA); + assertEquals("Haz clic aquí", nuevo.getString()); + assertEquals("https://example.com", nuevo.getStyle().getClickEvent().getValue()); + } + + @Test + void cambiaLosArgumentosDeUnTraducible() { + Component original = Component.translatable("chat.type.text", Component.literal("Hello"), "Hello"); + Component nuevo = Reescritor.reescribir(original, TABLA); + TranslatableContents contenido = (TranslatableContents) nuevo.getContents(); + assertEquals("chat.type.text", contenido.getKey()); + assertEquals("Hola", ((Component) contenido.getArgs()[0]).getString()); + assertEquals("Hola", contenido.getArgs()[1]); + } + + @Test + void noInventaTextoConEspaciosYAcentos() { + Tabla t = new Tabla(Map.of("Ñandú says hi", "Ñandú dice ¡hola! 😀")); + Component nuevo = Reescritor.reescribir(Component.literal(" Ñandú says hi "), t); + assertTrue(nuevo.getString().equals(" Ñandú dice ¡hola! 😀 ")); + } + + @Test + void cambiaLasLineasDeUnTooltipYDejaLoQueNoEsTexto() { + TooltipComponent imagen = new TooltipComponent() { + }; + FormattedText plano = FormattedText.of("Hello"); + Component otra = Component.literal("Other"); + List> elementos = new ArrayList<>(); + elementos.add(Either.left(Component.literal("Hello"))); + elementos.add(Either.right(imagen)); + elementos.add(Either.left(plano)); + elementos.add(Either.left(otra)); + + Reescritor.reescribirElementos(elementos, TABLA); + + assertEquals("Hola", ((Component) elementos.get(0).left().orElseThrow()).getString()); + assertSame(imagen, elementos.get(1).right().orElseThrow()); + assertSame(plano, elementos.get(2).left().orElseThrow()); + assertSame(otra, elementos.get(3).left().orElseThrow()); + } + + @Test + void losTooltipsSinTablaOSinListaNoHacenNada() { + Component original = Component.literal("Hello"); + List> elementos = new ArrayList<>(); + elementos.add(Either.left(original)); + + Reescritor.reescribirElementos(elementos, Tabla.VACIA); + Reescritor.reescribirElementos(null, TABLA); + + assertSame(original, elementos.get(0).left().orElseThrow()); + } +} diff --git a/mod-runtime/comun/reescritor-sin-reserva/src/main/java/io/github/consterna/modpackes/Reescritor.java b/mod-runtime/comun/reescritor-sin-reserva/src/main/java/io/github/consterna/modpackes/Reescritor.java new file mode 100644 index 0000000..48ba1c2 --- /dev/null +++ b/mod-runtime/comun/reescritor-sin-reserva/src/main/java/io/github/consterna/modpackes/Reescritor.java @@ -0,0 +1,113 @@ +package io.github.consterna.modpackes; + +import com.mojang.datafixers.util.Either; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.ComponentContents; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.contents.LiteralContents; +import net.minecraft.network.chat.contents.TranslatableContents; +import net.minecraft.world.inventory.tooltip.TooltipComponent; + +import java.util.ArrayList; +import java.util.List; + +/** + * Cambia el texto literal de un componente por su traducción, sin tocar estilos, colores, eventos de clic ni + * el resto de la estructura. Variante para Minecraft sin «fallback» en los textos traducibles (1.19.2). Nunca lanza por un componente raro: si algo no se entiende, se deja igual. + */ +public final class Reescritor { + private Reescritor() { + } + + /** El componente traducido, o {@code null} si no cambió nada (así no se copia lo que no hace falta). */ + public static Component reescribir(Component original, Tabla tabla) { + if (original == null || tabla.vacia()) { + return null; + } + ComponentContents contenido = original.getContents(); + ComponentContents nuevoContenido = contenido; + boolean cambio = false; + + if (contenido instanceof LiteralContents literal) { + String traducido = tabla.traducir(literal.text()); + if (traducido != null) { + nuevoContenido = new LiteralContents(traducido); + cambio = true; + } + } else if (contenido instanceof TranslatableContents traducible) { + Object[] argumentos = traducible.getArgs(); + Object[] nuevos = null; + for (int i = 0; i < argumentos.length; i++) { + Object reemplazo = null; + if (argumentos[i] instanceof Component hijo) { + reemplazo = reescribir(hijo, tabla); + } else if (argumentos[i] instanceof String texto) { + reemplazo = tabla.traducir(texto); + } + if (reemplazo != null) { + if (nuevos == null) { + nuevos = argumentos.clone(); + } + nuevos[i] = reemplazo; + } + } + if (nuevos != null) { + nuevoContenido = new TranslatableContents(traducible.getKey(), nuevos); + cambio = true; + } + } + + List hermanos = original.getSiblings(); + List nuevosHermanos = null; + for (int i = 0; i < hermanos.size(); i++) { + Component reemplazo = reescribir(hermanos.get(i), tabla); + if (reemplazo != null) { + if (nuevosHermanos == null) { + nuevosHermanos = new ArrayList<>(hermanos); + } + nuevosHermanos.set(i, reemplazo); + } + } + + if (!cambio && nuevosHermanos == null) { + return null; + } + MutableComponent resultado = MutableComponent.create(nuevoContenido).setStyle(original.getStyle()); + for (Component hermano : nuevosHermanos != null ? nuevosHermanos : hermanos) { + resultado.append(hermano); + } + return resultado; + } + + /** + * Cambia en su sitio las líneas de texto de un tooltip (las que son un {@link Component}); lo que no es texto + * o no está en la tabla se deja como está. + */ + public static void reescribirElementos(List> elementos, Tabla tabla) { + if (elementos == null || tabla.vacia()) { + return; + } + for (int i = 0; i < elementos.size(); i++) { + Either elemento = elementos.get(i); + if (elemento != null && elemento.left().orElse(null) instanceof Component linea) { + Component nuevo = reescribir(linea, tabla); + if (nuevo != null) { + elementos.set(i, Either.left(nuevo)); + } + } + } + } + + /** Cambia el texto de un botón, casilla o etiqueta si está en la tabla. */ + public static void reescribirMensaje(AbstractWidget widget, Tabla tabla) { + if (widget == null || tabla.vacia()) { + return; + } + Component nuevo = reescribir(widget.getMessage(), tabla); + if (nuevo != null) { + widget.setMessage(nuevo); + } + } +} diff --git a/mod-runtime/comun/reescritor-sin-reserva/src/test/java/io/github/consterna/modpackes/ReescritorTest.java b/mod-runtime/comun/reescritor-sin-reserva/src/test/java/io/github/consterna/modpackes/ReescritorTest.java new file mode 100644 index 0000000..592ea54 --- /dev/null +++ b/mod-runtime/comun/reescritor-sin-reserva/src/test/java/io/github/consterna/modpackes/ReescritorTest.java @@ -0,0 +1,113 @@ +package io.github.consterna.modpackes; + +import com.mojang.datafixers.util.Either; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.contents.TranslatableContents; +import net.minecraft.world.inventory.tooltip.TooltipComponent; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ReescritorTest { + private static final Tabla TABLA = new Tabla(Map.of( + "TPA request expired!", "¡La solicitud de TPA expiró!", + "Hello", "Hola", + "Click here", "Haz clic aquí")); + + @Test + void cambiaUnLiteralYConservaElEstilo() { + Style estilo = Style.EMPTY.withColor(ChatFormatting.RED).withBold(true); + Component original = Component.literal("TPA request expired!").setStyle(estilo); + Component nuevo = Reescritor.reescribir(original, TABLA); + assertEquals("¡La solicitud de TPA expiró!", nuevo.getString()); + assertEquals(estilo, nuevo.getStyle()); + } + + @Test + void sinCoincidenciaDevuelveNullYNoCopia() { + assertNull(Reescritor.reescribir(Component.literal("Nothing to see here"), TABLA)); + assertNull(Reescritor.reescribir(Component.literal("Hello"), Tabla.VACIA)); + assertNull(Reescritor.reescribir(null, TABLA)); + } + + @Test + void recorreLosHermanosYSoloCambiaLosQueCoinciden() { + MutableComponent raiz = Component.literal(" ").withStyle(ChatFormatting.AQUA); + raiz.append(Component.literal("Hello").withStyle(ChatFormatting.WHITE)); + raiz.append(Component.literal(" Steve")); + Component nuevo = Reescritor.reescribir(raiz, TABLA); + assertEquals(" Hola Steve", nuevo.getString()); + assertEquals(ChatFormatting.AQUA.getColor(), nuevo.getStyle().getColor().getValue()); + assertEquals(ChatFormatting.WHITE.getColor(), nuevo.getSiblings().get(0).getStyle().getColor().getValue()); + assertSame(raiz.getSiblings().get(1), nuevo.getSiblings().get(1)); // lo que no cambia, no se copia + } + + @Test + void conservaElEventoDeClic() { + Style estilo = Style.EMPTY.withClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, "https://example.com")); + Component nuevo = Reescritor.reescribir(Component.literal("Click here").setStyle(estilo), TABLA); + assertEquals("Haz clic aquí", nuevo.getString()); + assertEquals("https://example.com", nuevo.getStyle().getClickEvent().getValue()); + } + + @Test + void cambiaLosArgumentosDeUnTraducible() { + Component original = Component.translatable("chat.type.text", Component.literal("Hello"), "Hello"); + Component nuevo = Reescritor.reescribir(original, TABLA); + TranslatableContents contenido = (TranslatableContents) nuevo.getContents(); + assertEquals("chat.type.text", contenido.getKey()); + assertEquals("Hola", ((Component) contenido.getArgs()[0]).getString()); + assertEquals("Hola", contenido.getArgs()[1]); + } + + @Test + void noInventaTextoConEspaciosYAcentos() { + Tabla t = new Tabla(Map.of("Ñandú says hi", "Ñandú dice ¡hola! 😀")); + Component nuevo = Reescritor.reescribir(Component.literal(" Ñandú says hi "), t); + assertTrue(nuevo.getString().equals(" Ñandú dice ¡hola! 😀 ")); + } + + @Test + void cambiaLasLineasDeUnTooltipYDejaLoQueNoEsTexto() { + TooltipComponent imagen = new TooltipComponent() { + }; + FormattedText plano = FormattedText.of("Hello"); + Component otra = Component.literal("Other"); + List> elementos = new ArrayList<>(); + elementos.add(Either.left(Component.literal("Hello"))); + elementos.add(Either.right(imagen)); + elementos.add(Either.left(plano)); + elementos.add(Either.left(otra)); + + Reescritor.reescribirElementos(elementos, TABLA); + + assertEquals("Hola", ((Component) elementos.get(0).left().orElseThrow()).getString()); + assertSame(imagen, elementos.get(1).right().orElseThrow()); + assertSame(plano, elementos.get(2).left().orElseThrow()); + assertSame(otra, elementos.get(3).left().orElseThrow()); + } + + @Test + void losTooltipsSinTablaOSinListaNoHacenNada() { + Component original = Component.literal("Hello"); + List> elementos = new ArrayList<>(); + elementos.add(Either.left(original)); + + Reescritor.reescribirElementos(elementos, Tabla.VACIA); + Reescritor.reescribirElementos(null, TABLA); + + assertSame(original, elementos.get(0).left().orElseThrow()); + } +} diff --git a/mod-runtime/comun/reescritor-texto/src/main/java/io/github/consterna/modpackes/Reescritor.java b/mod-runtime/comun/reescritor-texto/src/main/java/io/github/consterna/modpackes/Reescritor.java new file mode 100644 index 0000000..02a3a48 --- /dev/null +++ b/mod-runtime/comun/reescritor-texto/src/main/java/io/github/consterna/modpackes/Reescritor.java @@ -0,0 +1,115 @@ +package io.github.consterna.modpackes; + +import com.mojang.datafixers.util.Either; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.TextComponent; +import net.minecraft.network.chat.TranslatableComponent; +import net.minecraft.world.inventory.tooltip.TooltipComponent; + +import java.util.ArrayList; +import java.util.List; + +/** + * Cambia el texto literal de un componente por su traducción, sin tocar estilos, colores, eventos de clic ni + * el resto de la estructura. Nunca lanza por un componente raro: si algo no se entiende, se deja igual. + */ +public final class Reescritor { + private Reescritor() { + } + + /** El componente traducido, o {@code null} si no cambió nada (así no se copia lo que no hace falta). */ + public static Component reescribir(Component original, Tabla tabla) { + if (original == null || tabla.vacia()) { + return null; + } + // Antes de 1.19 el texto es un componente propio (TextComponent, TranslatableComponent), no un «contenido» + String textoNuevo = null; + Object[] argumentosNuevos = null; + boolean cambio = false; + + if (original instanceof TextComponent literal) { + textoNuevo = tabla.traducir(literal.getText()); + cambio = textoNuevo != null; + } else if (original instanceof TranslatableComponent traducible) { + Object[] argumentos = traducible.getArgs(); + for (int i = 0; i < argumentos.length; i++) { + Object reemplazo = null; + if (argumentos[i] instanceof Component hijo) { + reemplazo = reescribir(hijo, tabla); + } else if (argumentos[i] instanceof String texto) { + reemplazo = tabla.traducir(texto); + } + if (reemplazo != null) { + if (argumentosNuevos == null) { + argumentosNuevos = argumentos.clone(); + } + argumentosNuevos[i] = reemplazo; + } + } + cambio = argumentosNuevos != null; + } + + List hermanos = original.getSiblings(); + List nuevosHermanos = null; + for (int i = 0; i < hermanos.size(); i++) { + Component reemplazo = reescribir(hermanos.get(i), tabla); + if (reemplazo != null) { + if (nuevosHermanos == null) { + nuevosHermanos = new ArrayList<>(hermanos); + } + nuevosHermanos.set(i, reemplazo); + } + } + + if (!cambio && nuevosHermanos == null) { + return null; + } + MutableComponent resultado; + if (original instanceof TextComponent literal) { + resultado = new TextComponent(textoNuevo != null ? textoNuevo : literal.getText()); + } else if (original instanceof TranslatableComponent traducible) { + resultado = new TranslatableComponent( + traducible.getKey(), argumentosNuevos != null ? argumentosNuevos : traducible.getArgs()); + } else { + resultado = original.plainCopy(); // otro tipo de componente: se copia tal cual y solo cambian sus hermanos + } + resultado.setStyle(original.getStyle()); + for (Component hermano : nuevosHermanos != null ? nuevosHermanos : hermanos) { + resultado.append(hermano); + } + return resultado; + } + + /** + * Cambia en su sitio las líneas de texto de un tooltip (las que son un {@link Component}); lo que no es texto + * o no está en la tabla se deja como está. + */ + public static void reescribirElementos(List> elementos, Tabla tabla) { + if (elementos == null || tabla.vacia()) { + return; + } + for (int i = 0; i < elementos.size(); i++) { + Either elemento = elementos.get(i); + if (elemento != null && elemento.left().orElse(null) instanceof Component linea) { + Component nuevo = reescribir(linea, tabla); + if (nuevo != null) { + elementos.set(i, Either.left(nuevo)); + } + } + } + } + + /** Cambia el texto de un botón, casilla o etiqueta si está en la tabla. */ + public static void reescribirMensaje(AbstractWidget widget, Tabla tabla) { + if (widget == null || tabla.vacia()) { + return; + } + Component nuevo = reescribir(widget.getMessage(), tabla); + if (nuevo != null) { + widget.setMessage(nuevo); + } + } +} diff --git a/mod-runtime/comun/reescritor-texto/src/test/java/io/github/consterna/modpackes/ReescritorTest.java b/mod-runtime/comun/reescritor-texto/src/test/java/io/github/consterna/modpackes/ReescritorTest.java new file mode 100644 index 0000000..2d10c38 --- /dev/null +++ b/mod-runtime/comun/reescritor-texto/src/test/java/io/github/consterna/modpackes/ReescritorTest.java @@ -0,0 +1,114 @@ +package io.github.consterna.modpackes; + +import com.mojang.datafixers.util.Either; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.TextComponent; +import net.minecraft.network.chat.TranslatableComponent; +import net.minecraft.world.inventory.tooltip.TooltipComponent; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ReescritorTest { + private static final Tabla TABLA = new Tabla(Map.of( + "TPA request expired!", "¡La solicitud de TPA expiró!", + "Hello", "Hola", + "Click here", "Haz clic aquí")); + + @Test + void cambiaUnLiteralYConservaElEstilo() { + Style estilo = Style.EMPTY.withColor(ChatFormatting.RED).withBold(true); + Component original = new TextComponent("TPA request expired!").setStyle(estilo); + Component nuevo = Reescritor.reescribir(original, TABLA); + assertEquals("¡La solicitud de TPA expiró!", nuevo.getString()); + assertEquals(estilo, nuevo.getStyle()); + } + + @Test + void sinCoincidenciaDevuelveNullYNoCopia() { + assertNull(Reescritor.reescribir(new TextComponent("Nothing to see here"), TABLA)); + assertNull(Reescritor.reescribir(new TextComponent("Hello"), Tabla.VACIA)); + assertNull(Reescritor.reescribir(null, TABLA)); + } + + @Test + void recorreLosHermanosYSoloCambiaLosQueCoinciden() { + MutableComponent raiz = new TextComponent(" ").withStyle(ChatFormatting.AQUA); + raiz.append(new TextComponent("Hello").withStyle(ChatFormatting.WHITE)); + raiz.append(new TextComponent(" Steve")); + Component nuevo = Reescritor.reescribir(raiz, TABLA); + assertEquals(" Hola Steve", nuevo.getString()); + assertEquals(ChatFormatting.AQUA.getColor(), nuevo.getStyle().getColor().getValue()); + assertEquals(ChatFormatting.WHITE.getColor(), nuevo.getSiblings().get(0).getStyle().getColor().getValue()); + assertSame(raiz.getSiblings().get(1), nuevo.getSiblings().get(1)); // lo que no cambia, no se copia + } + + @Test + void conservaElEventoDeClic() { + Style estilo = Style.EMPTY.withClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, "https://example.com")); + Component nuevo = Reescritor.reescribir(new TextComponent("Click here").setStyle(estilo), TABLA); + assertEquals("Haz clic aquí", nuevo.getString()); + assertEquals("https://example.com", nuevo.getStyle().getClickEvent().getValue()); + } + + @Test + void cambiaLosArgumentosDeUnTraducible() { + Component original = new TranslatableComponent("chat.type.text", new TextComponent("Hello"), "Hello"); + Component nuevo = Reescritor.reescribir(original, TABLA); + TranslatableComponent contenido = (TranslatableComponent) nuevo; + assertEquals("chat.type.text", contenido.getKey()); + assertEquals("Hola", ((Component) contenido.getArgs()[0]).getString()); + assertEquals("Hola", contenido.getArgs()[1]); + } + + @Test + void noInventaTextoConEspaciosYAcentos() { + Tabla t = new Tabla(Map.of("Ñandú says hi", "Ñandú dice ¡hola! 😀")); + Component nuevo = Reescritor.reescribir(new TextComponent(" Ñandú says hi "), t); + assertTrue(nuevo.getString().equals(" Ñandú dice ¡hola! 😀 ")); + } + + @Test + void cambiaLasLineasDeUnTooltipYDejaLoQueNoEsTexto() { + TooltipComponent imagen = new TooltipComponent() { + }; + FormattedText plano = FormattedText.of("Hello"); + Component otra = new TextComponent("Other"); + List> elementos = new ArrayList<>(); + elementos.add(Either.left(new TextComponent("Hello"))); + elementos.add(Either.right(imagen)); + elementos.add(Either.left(plano)); + elementos.add(Either.left(otra)); + + Reescritor.reescribirElementos(elementos, TABLA); + + assertEquals("Hola", ((Component) elementos.get(0).left().orElseThrow()).getString()); + assertSame(imagen, elementos.get(1).right().orElseThrow()); + assertSame(plano, elementos.get(2).left().orElseThrow()); + assertSame(otra, elementos.get(3).left().orElseThrow()); + } + + @Test + void losTooltipsSinTablaOSinListaNoHacenNada() { + Component original = new TextComponent("Hello"); + List> elementos = new ArrayList<>(); + elementos.add(Either.left(original)); + + Reescritor.reescribirElementos(elementos, Tabla.VACIA); + Reescritor.reescribirElementos(null, TABLA); + + assertSame(original, elementos.get(0).left().orElseThrow()); + } +} diff --git a/mod-runtime/comun/tabla/src/main/java/io/github/consterna/modpackes/Tabla.java b/mod-runtime/comun/tabla/src/main/java/io/github/consterna/modpackes/Tabla.java new file mode 100644 index 0000000..5f2e72c --- /dev/null +++ b/mod-runtime/comun/tabla/src/main/java/io/github/consterna/modpackes/Tabla.java @@ -0,0 +1,309 @@ +package io.github.consterna.modpackes; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.io.Reader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Tabla de textos «inglés → español» que genera ModpackES. Hay dos clases de entrada: + *
    + *
  • Exactas: solo coinciden si el texto es idéntico (salvo los espacios de los bordes, que se conservan).
  • + *
  • Plantillas: la clave lleva {@code %s} o {@code %d} (como el {@code String.format} del mod original) y coinciden + * con cualquier texto que sea ese mismo molde con otros datos: «Hello %s!» coincide con «Hello Steve!». El español + * reutiliza lo capturado; puede llevar {@code %1$s}, {@code %2$s} para cambiar el orden.
  • + *
+ * No hay traducción aproximada ni en vivo. + */ +public final class Tabla { + public static final Tabla VACIA = new Tabla(Collections.emptyMap()); + + private static final Pattern MARCADOR = Pattern.compile("%(?:(\\d+)\\$)?([sd%])"); + private static final int MINIMO_FIJO = 4; // una plantilla con casi nada de texto fijo coincidiría con cualquier cosa + private static final int TAMANO_CACHE = 512; + private static final String SIN_COINCIDENCIA = "\u0000"; + + private final Map exactos; + private final List plantillas; + private final Map cache = new LinkedHashMap(64, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > TAMANO_CACHE; + } + }; + + public Tabla(Map textos) { + Map exactos = new HashMap<>(); + List plantillas = new ArrayList<>(); + for (Map.Entry e : textos.entrySet()) { + if (esPlantilla(e.getKey())) { + Plantilla p = Plantilla.crear(e.getKey(), e.getValue()); + if (p != null) { + plantillas.add(p); + } + } else { + exactos.put(e.getKey(), e.getValue()); + } + } + plantillas.sort(Comparator.comparingInt((Plantilla p) -> p.fijo.length()).reversed()); // la más específica primero + this.exactos = Collections.unmodifiableMap(exactos); + this.plantillas = Collections.unmodifiableList(plantillas); + } + + /** Lee un objeto JSON {"inglés": "español"}. Lo que no sea texto se ignora. */ + public static Tabla leer(Reader lector) throws IOException { + JsonElement raiz; + try { + raiz = new JsonParser().parse(lector); // el único que existe también en el Gson de 1.16.5 + } catch (JsonParseException e) { + throw new IOException("La tabla de textos no es JSON válido", e); + } + if (!raiz.isJsonObject()) { + throw new IOException("La tabla de textos no es un objeto JSON"); + } + JsonObject objeto = raiz.getAsJsonObject(); + Map mapa = new HashMap<>(); + for (Map.Entry e : objeto.entrySet()) { + JsonElement v = e.getValue(); + if (v.isJsonPrimitive() && v.getAsJsonPrimitive().isString() && !v.getAsString().isEmpty()) { + mapa.put(e.getKey(), v.getAsString()); + } + } + return new Tabla(mapa); + } + + /** Une dos tablas; si un texto está en las dos, manda la de {@code otra}. */ + public Tabla combinar(Tabla otra) { + if (otra.vacia()) { + return this; + } + Map mapa = new HashMap<>(this.comoMapa()); + mapa.putAll(otra.comoMapa()); + return new Tabla(mapa); + } + + private Map comoMapa() { + Map mapa = new HashMap<>(exactos); + for (Plantilla p : plantillas) { + mapa.put(p.clave, p.valor); + } + return mapa; + } + + public boolean vacia() { + return exactos.isEmpty() && plantillas.isEmpty(); + } + + public int tamano() { + return exactos.size() + plantillas.size(); + } + + public int plantillas() { + return plantillas.size(); + } + + /** La traducción de {@code texto}, o {@code null} si no está en la tabla o no cambia nada. */ + public String traducir(String texto) { + if (texto == null || texto.isEmpty() || vacia()) { + return null; + } + String directo = buscar(texto); + if (directo != null) { + return directo.equals(texto) ? null : directo; + } + String nucleo = sinBordes(texto); + if (nucleo.isEmpty() || nucleo.length() == texto.length()) { + return null; + } + String traduccion = buscar(nucleo); + if (traduccion == null || traduccion.equals(nucleo)) { + return null; + } + int inicio = espaciosAlInicio(texto); + int fin = espaciosAlFinal(texto); + return texto.substring(0, inicio) + traduccion + texto.substring(texto.length() - fin); + } + + // Como String.strip(), String.stripLeading() y String.stripTrailing() de Java 11, que Java 8 no tiene + private static int espaciosAlInicio(String texto) { + int i = 0; + while (i < texto.length() && Character.isWhitespace(texto.charAt(i))) { + i++; + } + return i; + } + + private static int espaciosAlFinal(String texto) { + int i = 0; + while (i < texto.length() && Character.isWhitespace(texto.charAt(texto.length() - 1 - i))) { + i++; + } + return i; + } + + private static String sinBordes(String texto) { + int inicio = espaciosAlInicio(texto); + return inicio == texto.length() ? "" : texto.substring(inicio, texto.length() - espaciosAlFinal(texto)); + } + + private String buscar(String texto) { + String exacto = exactos.get(texto); + if (exacto != null) { + return exacto; + } + if (plantillas.isEmpty()) { + return null; + } + synchronized (cache) { // los tooltips piden el mismo texto cada cuadro: se recuerda el resultado + String guardado = cache.get(texto); + if (guardado != null) { + return guardado.equals(SIN_COINCIDENCIA) ? null : guardado; + } + String resultado = null; + for (Plantilla p : plantillas) { + resultado = p.aplicar(texto); + if (resultado != null) { + break; + } + } + cache.put(texto, resultado == null ? SIN_COINCIDENCIA : resultado); + return resultado; + } + } + + private static boolean esPlantilla(String clave) { + Matcher m = MARCADOR.matcher(clave); + while (m.find()) { + if (!m.group(2).equals("%")) { + return true; + } + } + return false; + } + + /** Un molde con datos variables: «Hello %s!» → «¡Hola %s!». */ + private static final class Plantilla { + final String clave; + final String valor; + final String fijo; // el texto de la clave sin los marcadores + final String prefijo; // lo que va antes del primer marcador: descarta rápido lo que no coincide + final Pattern patron; + final List destino; // trozos de texto y números de grupo (Integer, desde 1) + + private Plantilla(String clave, String valor, String fijo, String prefijo, Pattern patron, List destino) { + this.clave = clave; + this.valor = valor; + this.fijo = fijo; + this.prefijo = prefijo; + this.patron = patron; + this.destino = destino; + } + + /** {@code null} si la plantilla no es segura (poco texto fijo o marcadores que no cuadran). */ + static Plantilla crear(String clave, String valor) { + StringBuilder regex = new StringBuilder("^"); + StringBuilder fijo = new StringBuilder(); + String prefijo = null; + int grupos = 0; + int ultimo = 0; + Matcher m = MARCADOR.matcher(clave); + while (m.find()) { + String tramo = clave.substring(ultimo, m.start()); + fijo.append(tramo); + if (!tramo.isEmpty()) { + regex.append(Pattern.quote(tramo)); + } + if (m.group(2).equals("%")) { + fijo.append('%'); + regex.append(Pattern.quote("%")); + } else { + if (prefijo == null) { + prefijo = clave.substring(0, m.start()); + } + grupos++; + regex.append(m.group(2).equals("d") ? "(-?\\d+)" : "(.+?)"); + } + ultimo = m.end(); + } + String cola = clave.substring(ultimo); + fijo.append(cola); + if (!cola.isEmpty()) { + regex.append(Pattern.quote(cola)); + } + regex.append('$'); + if (grupos == 0 || sinBordes(fijo.toString()).length() < MINIMO_FIJO) { + return null; + } + List destino = destino(valor, grupos); + if (destino == null) { + return null; + } + return new Plantilla(clave, valor, fijo.toString(), prefijo == null ? "" : prefijo, + Pattern.compile(regex.toString(), Pattern.DOTALL), destino); + } + + private static List destino(String valor, int grupos) { + List partes = new ArrayList<>(); + Matcher m = MARCADOR.matcher(valor); + int ultimo = 0; + int siguiente = 1; + boolean[] usado = new boolean[grupos + 1]; + while (m.find()) { + if (m.start() > ultimo) { + partes.add(valor.substring(ultimo, m.start())); + } + if (m.group(2).equals("%")) { + partes.add("%"); + } else { + int indice = m.group(1) != null ? Integer.parseInt(m.group(1)) : siguiente++; + if (indice < 1 || indice > grupos) { + return null; + } + usado[indice] = true; + partes.add(indice); + } + ultimo = m.end(); + } + if (ultimo < valor.length()) { + partes.add(valor.substring(ultimo)); + } + for (int i = 1; i <= grupos; i++) { + if (!usado[i]) { + return null; // la traducción perdió un dato: mejor no usarla + } + } + return partes; + } + + String aplicar(String texto) { + if (!prefijo.isEmpty() && !texto.startsWith(prefijo)) { + return null; + } + Matcher m = patron.matcher(texto); + if (!m.matches()) { + return null; + } + StringBuilder salida = new StringBuilder(); + for (Object parte : destino) { + if (parte instanceof Integer) { + salida.append(m.group((Integer) parte)); + } else { + salida.append((String) parte); + } + } + return salida.toString(); + } + } +} diff --git a/mod-runtime/comun/tabla/src/test/java/io/github/consterna/modpackes/TablaTest.java b/mod-runtime/comun/tabla/src/test/java/io/github/consterna/modpackes/TablaTest.java new file mode 100644 index 0000000..a9a7e52 --- /dev/null +++ b/mod-runtime/comun/tabla/src/test/java/io/github/consterna/modpackes/TablaTest.java @@ -0,0 +1,142 @@ +package io.github.consterna.modpackes; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.StringReader; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TablaTest { + private static Tabla tabla(String... pares) { + Map mapa = new java.util.HashMap<>(); + for (int i = 0; i < pares.length; i += 2) { + mapa.put(pares[i], pares[i + 1]); + } + return new Tabla(mapa); + } + + @Test + void coincidenciaExactaYSinCoincidencia() { + Tabla t = tabla("TPA request expired!", "¡La solicitud de TPA expiró!"); + assertEquals("¡La solicitud de TPA expiró!", t.traducir("TPA request expired!")); + assertNull(t.traducir("tpa request expired!")); // mayúsculas distintas: no es el mismo texto + assertNull(t.traducir("TPA request expired! Now")); + assertNull(t.traducir("")); + assertNull(t.traducir(null)); + } + + @Test + void conservaLosEspaciosDeLosBordes() { + Tabla t = tabla("Hello there", "Hola"); + assertEquals(" Hola\n", t.traducir(" Hello there\n")); + assertNull(t.traducir(" ")); + } + + @Test + void unTextoIgualQueSuTraduccionNoCuentaComoCambio() { + Tabla t = tabla("Creeper", "Creeper"); + assertNull(t.traducir("Creeper")); + } + + @Test + void conservaTildesEneYEmoji() { + Tabla t = tabla("Pick 😀", "Elige ñandú ¡sí! 日本語 §a%s"); + assertEquals("Elige ñandú ¡sí! 日本語 §a%s", t.traducir("Pick 😀")); + } + + @Test + void leeJsonValidoEIgnoraLoQueNoEsTexto() throws IOException { + Tabla t = Tabla.leer(new StringReader("{\"a b\": \"uno\", \"n\": 5, \"v\": \"\", \"l\": [\"x\"], \"z\": null}")); + assertEquals(1, t.tamano()); + assertEquals("uno", t.traducir("a b")); + } + + @Test + void jsonRotoOQueNoEsObjetoFallaConIOException() { + assertThrows(IOException.class, () -> Tabla.leer(new StringReader("{no es json"))); + assertThrows(IOException.class, () -> Tabla.leer(new StringReader("[1, 2]"))); + assertThrows(IOException.class, () -> Tabla.leer(new StringReader("\"texto\""))); + } + + @Test + void combinarDejaMandarALaSegunda() { + Tabla total = tabla("a b", "viejo", "c d", "queda").combinar(tabla("a b", "nuevo")); + assertEquals("nuevo", total.traducir("a b")); + assertEquals("queda", total.traducir("c d")); + assertTrue(Tabla.VACIA.vacia()); + assertFalse(total.vacia()); + assertEquals(total, total.combinar(Tabla.VACIA)); + } + + @Test + void plantillaConUnDatoVariable() { + Tabla t = tabla("Hello %s, welcome back!", "¡Hola %s, bienvenido de nuevo!"); + assertEquals("¡Hola Steve, bienvenido de nuevo!", t.traducir("Hello Steve, welcome back!")); + assertEquals("¡Hola Ñandú 😀, bienvenido de nuevo!", t.traducir("Hello Ñandú 😀, welcome back!")); + assertNull(t.traducir("Goodbye Steve, welcome back!")); + assertNull(t.traducir("Hello Steve")); + } + + @Test + void plantillaConNumeroSoloCoincideConNumeros() { + Tabla t = tabla("You have %d items left", "Te quedan %d objetos"); + assertEquals("Te quedan 5 objetos", t.traducir("You have 5 items left")); + assertEquals("Te quedan -3 objetos", t.traducir("You have -3 items left")); + assertNull(t.traducir("You have many items left")); + } + + @Test + void plantillaPuedeCambiarElOrden() { + Tabla t = tabla("%s gave %d coins", "%2$d monedas de %1$s"); + assertEquals("7 monedas de Laly", t.traducir("Laly gave 7 coins")); + } + + @Test + void plantillaConBordesYPorcentajeLiteral() { + Tabla t = tabla("Progress: %d%% of %s", "Progreso: %d%% de %s"); + assertEquals(" Progreso: 50% de la misión ", t.traducir(" Progress: 50% of la misión ")); + } + + @Test + void plantillasInseguras() { + // sin texto fijo: coincidiría con todo + assertNull(tabla("%s", "%s").traducir("cualquier cosa")); + assertNull(tabla("%s %s", "%s %s").traducir("uno dos")); + // la traducción pierde un dato o inventa uno + assertNull(tabla("Hello %s and %s here", "Hola %s aquí").traducir("Hello A and B here")); + assertNull(tabla("Hello %s there friend", "Hola %s y %s").traducir("Hello A there friend")); + assertNull(tabla("Hello %s there friend", "Hola %3$s").traducir("Hello A there friend")); + } + + @Test + void laPlantillaMasEspecificaGana() { + Tabla t = tabla( + "Welcome %s", "Bienvenido %s", + "Welcome %s to the farm", "Bienvenido %s a la granja"); + assertEquals("Bienvenido Steve a la granja", t.traducir("Welcome Steve to the farm")); + assertEquals("Bienvenido Steve", t.traducir("Welcome Steve")); + } + + @Test + void caracteresDeRegexEnElTextoFijoNoSonEspeciales() { + Tabla t = tabla("Cost (%d) [gold]. Really?", "Costo (%d) [oro]. ¿En serio?"); + assertEquals("Costo (9) [oro]. ¿En serio?", t.traducir("Cost (9) [gold]. Really?")); + assertNull(t.traducir("Cost 9 gold. Really?")); + } + + @Test + void lasPlantillasSobrevivenAlCombinarYSeCuentan() { + Tabla a = tabla("Hello %s, welcome back!", "¡Hola %s, bienvenido de nuevo!", "Exact text here", "Texto exacto"); + Tabla total = a.combinar(tabla("Another %d things here", "Otras %d cosas aquí")); + assertEquals(3, total.tamano()); + assertEquals(2, total.plantillas()); + assertEquals("Otras 3 cosas aquí", total.traducir("Another 3 things here")); + assertEquals("Texto exacto", total.traducir("Exact text here")); + } +} diff --git a/mod-runtime/fabric-1.19.4/.gitignore b/mod-runtime/fabric-1.19.4/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/fabric-1.19.4/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/fabric-1.19.4/build.gradle b/mod-runtime/fabric-1.19.4/build.gradle new file mode 100644 index 0000000..feb910e --- /dev/null +++ b/mod-runtime/fabric-1.19.4/build.gradle @@ -0,0 +1,62 @@ +plugins { + id 'net.fabricmc.fabric-loom-remap' version "${loom_version}" +} + +base { + archivesName = "modpackes_runtime-fabric-${project.minecraft_version}" +} + +version = project.mod_version +group = project.mod_group_id + +repositories { +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings loom.officialMojangMappings() + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +processResources { + def propiedades = [ + version : project.mod_version, + mod_id : project.mod_id, + mod_name : project.mod_name, + mod_license: project.mod_license, + mod_authors: project.mod_authors, + mod_description: project.mod_description, + minecraft_version: project.minecraft_version, + loader_version: project.loader_version, + ] + inputs.properties propiedades + filesMatching("fabric.mod.json") { + expand propiedades + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 17 // Minecraft 1.19.4 funciona con Java 17 + it.options.encoding = 'UTF-8' +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-literal/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-literal/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/fabric-1.19.4/gradle.properties b/mod-runtime/fabric-1.19.4/gradle.properties new file mode 100644 index 0000000..2145cf8 --- /dev/null +++ b/mod-runtime/fabric-1.19.4/gradle.properties @@ -0,0 +1,16 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +# Fabric API 0.87.2 para 1.19.4. Loom 1.17 corre con Java 21 (la 1.19 pide Java 25); el mod se compila a Java 17. +minecraft_version=1.19.4 +loader_version=0.14.21 +loom_version=1.17.21 +fabric_api_version=0.87.2+1.19.4 + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/fabric-1.19.4/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/fabric-1.19.4/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9fa7f28 Binary files /dev/null and b/mod-runtime/fabric-1.19.4/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/fabric-1.19.4/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/fabric-1.19.4/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f69664a --- /dev/null +++ b/mod-runtime/fabric-1.19.4/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip +networkTimeout=120000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/fabric-1.19.4/gradlew b/mod-runtime/fabric-1.19.4/gradlew new file mode 100644 index 0000000..249efbb --- /dev/null +++ b/mod-runtime/fabric-1.19.4/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/fabric-1.19.4/gradlew.bat b/mod-runtime/fabric-1.19.4/gradlew.bat new file mode 100644 index 0000000..3185a43 --- /dev/null +++ b/mod-runtime/fabric-1.19.4/gradlew.bat @@ -0,0 +1,112 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/mod-runtime/fabric-1.19.4/settings.gradle b/mod-runtime/fabric-1.19.4/settings.gradle new file mode 100644 index 0000000..604d9a6 --- /dev/null +++ b/mod-runtime/fabric-1.19.4/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'modpackes_runtime' diff --git a/mod-runtime/fabric-1.19.4/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/fabric-1.19.4/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..bfcb994 --- /dev/null +++ b/mod-runtime/fabric-1.19.4/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,135 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback; +import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.Screens; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes del juego (los de + * {@code tellraw}, los de los mods y la barra de acción) y tooltips cuyo texto sea exactamente uno de la tabla + * {@code assets/modpackes_runtime/textos/.json} que genera ModpackES (o encaje en una de sus plantillas con + * {@code %s} o {@code %d}). Si el juego no está en una variante de español, no hace nada. + * + *

También cambia el texto de los botones de una pantalla al abrirla. Fabric API no tiene evento para los + * tooltips que no son de objetos ni para el texto que una pantalla dibuja directamente: esos no se tocan. + * + *

El chat de otros jugadores no se toca: Fabric API no permite modificarlo, y no hace falta. + */ +public final class ModpackESRuntime implements ClientModInitializer { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + @Override + public void onInitializeClient() { + ResourceManagerHelper.get(PackType.CLIENT_RESOURCES).registerReloadListener(new Cargador()); + ClientReceiveMessageEvents.MODIFY_GAME.register(ModpackESRuntime::alRecibirMensaje); + ItemTooltipCallback.EVENT.register((pila, contexto, lineas) -> alCrearTooltip(lineas)); + ScreenEvents.AFTER_INIT.register((cliente, pantalla, ancho, alto) -> alIniciarPantalla(pantalla)); + } + + private static Component alRecibirMensaje(Component mensaje, boolean barraDeAccion) { + try { + Component nuevo = Reescritor.reescribir(mensaje, tabla); + return nuevo != null ? nuevo : mensaje; + } catch (RuntimeException | LinkageError e) { + avisar(e); + return mensaje; + } + } + + private static void alCrearTooltip(List lineas) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(Screen pantalla) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (AbstractWidget widget : Screens.getButtons(pantalla)) { + Reescritor.reescribirMensaje(widget, actual); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador implements SimpleSynchronousResourceReloadListener { + @Override + public ResourceLocation getFabricId() { + return new ResourceLocation(ID, "textos"); + } + + @Override + public void onResourceManagerReload(ResourceManager recursos) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + ResourceLocation ruta = new ResourceLocation(ID, "textos/" + variante + ".json"); + for (Resource recurso : recursos.getResourceStack(ruta)) { // de menor a mayor prioridad + try (Reader lector = recurso.openAsReader()) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.sourcePackId(), e); + } + } + if (!total.vacia()) { + break; + } + } + tabla = total; + LOG.info("ModpackES Runtime: {} textos en la tabla ({} plantillas)", total.tamano(), total.plantillas()); + } + } +} diff --git a/mod-runtime/fabric-1.19.4/src/main/resources/fabric.mod.json b/mod-runtime/fabric-1.19.4/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..af845f1 --- /dev/null +++ b/mod-runtime/fabric-1.19.4/src/main/resources/fabric.mod.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "id": "${mod_id}", + "version": "${version}", + "name": "${mod_name}", + "description": "${mod_description}", + "authors": ["${mod_authors}"], + "contact": { + "sources": "https://github.com/Consterna/ModpackES" + }, + "license": "${mod_license}", + "environment": "client", + "entrypoints": { + "client": ["io.github.consterna.modpackes.ModpackESRuntime"] + }, + "depends": { + "fabricloader": ">=0.14.0", + "minecraft": "${minecraft_version}", + "java": ">=17", + "fabric-message-api-v1": "*", + "fabric-screen-api-v1": "*", + "fabric-item-api-v1": "*", + "fabric-resource-loader-v0": "*" + } +} diff --git a/mod-runtime/fabric-1.20.1/.gitignore b/mod-runtime/fabric-1.20.1/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/fabric-1.20.1/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/fabric-1.20.1/build.gradle b/mod-runtime/fabric-1.20.1/build.gradle new file mode 100644 index 0000000..11e61ed --- /dev/null +++ b/mod-runtime/fabric-1.20.1/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'net.fabricmc.fabric-loom-remap' version "${loom_version}" +} + +base { + archivesName = "modpackes_runtime-fabric-${project.minecraft_version}" +} + +version = project.mod_version +group = project.mod_group_id + +repositories { +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings loom.officialMojangMappings() + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +processResources { + def propiedades = [ + version : project.mod_version, + mod_id : project.mod_id, + mod_name : project.mod_name, + mod_license: project.mod_license, + mod_authors: project.mod_authors, + mod_description: project.mod_description, + minecraft_version: project.minecraft_version, + minecraft_range: project.findProperty('minecraft_range') ?: project.minecraft_version, + loader_version: project.loader_version, + ] + inputs.properties propiedades + filesMatching("fabric.mod.json") { + expand propiedades + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 17 // Minecraft 1.20.1 funciona con Java 17 + it.options.encoding = 'UTF-8' +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-literal/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-literal/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/fabric-1.20.1/gradle.properties b/mod-runtime/fabric-1.20.1/gradle.properties new file mode 100644 index 0000000..06011e8 --- /dev/null +++ b/mod-runtime/fabric-1.20.1/gradle.properties @@ -0,0 +1,18 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +# Versiones del ejemplo oficial de Fabric para 1.20.1 (FabricMC/fabric-example-mod, rama 1.20.1). Loom 1.17 corre con Java 21 (la 1.19 pide Java 25); el mod se compila a Java 17. +minecraft_version=1.20.1 +# Versiones en las que el mismo código compila y pasa las pruebas (comprobado con `construir_mod.py --propiedad`) +minecraft_range=>=1.20 <=1.20.2 +loader_version=0.20.1 +loom_version=1.17.21 +fabric_api_version=0.92.12+1.20.1 + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/fabric-1.20.1/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/fabric-1.20.1/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9fa7f28 Binary files /dev/null and b/mod-runtime/fabric-1.20.1/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/fabric-1.20.1/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/fabric-1.20.1/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f69664a --- /dev/null +++ b/mod-runtime/fabric-1.20.1/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip +networkTimeout=120000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/fabric-1.20.1/gradlew b/mod-runtime/fabric-1.20.1/gradlew new file mode 100644 index 0000000..249efbb --- /dev/null +++ b/mod-runtime/fabric-1.20.1/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/fabric-1.20.1/gradlew.bat b/mod-runtime/fabric-1.20.1/gradlew.bat new file mode 100644 index 0000000..3185a43 --- /dev/null +++ b/mod-runtime/fabric-1.20.1/gradlew.bat @@ -0,0 +1,112 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/mod-runtime/fabric-1.20.1/settings.gradle b/mod-runtime/fabric-1.20.1/settings.gradle new file mode 100644 index 0000000..604d9a6 --- /dev/null +++ b/mod-runtime/fabric-1.20.1/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'modpackes_runtime' diff --git a/mod-runtime/fabric-1.20.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/fabric-1.20.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..bfcb994 --- /dev/null +++ b/mod-runtime/fabric-1.20.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,135 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback; +import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.Screens; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes del juego (los de + * {@code tellraw}, los de los mods y la barra de acción) y tooltips cuyo texto sea exactamente uno de la tabla + * {@code assets/modpackes_runtime/textos/.json} que genera ModpackES (o encaje en una de sus plantillas con + * {@code %s} o {@code %d}). Si el juego no está en una variante de español, no hace nada. + * + *

También cambia el texto de los botones de una pantalla al abrirla. Fabric API no tiene evento para los + * tooltips que no son de objetos ni para el texto que una pantalla dibuja directamente: esos no se tocan. + * + *

El chat de otros jugadores no se toca: Fabric API no permite modificarlo, y no hace falta. + */ +public final class ModpackESRuntime implements ClientModInitializer { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + @Override + public void onInitializeClient() { + ResourceManagerHelper.get(PackType.CLIENT_RESOURCES).registerReloadListener(new Cargador()); + ClientReceiveMessageEvents.MODIFY_GAME.register(ModpackESRuntime::alRecibirMensaje); + ItemTooltipCallback.EVENT.register((pila, contexto, lineas) -> alCrearTooltip(lineas)); + ScreenEvents.AFTER_INIT.register((cliente, pantalla, ancho, alto) -> alIniciarPantalla(pantalla)); + } + + private static Component alRecibirMensaje(Component mensaje, boolean barraDeAccion) { + try { + Component nuevo = Reescritor.reescribir(mensaje, tabla); + return nuevo != null ? nuevo : mensaje; + } catch (RuntimeException | LinkageError e) { + avisar(e); + return mensaje; + } + } + + private static void alCrearTooltip(List lineas) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(Screen pantalla) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (AbstractWidget widget : Screens.getButtons(pantalla)) { + Reescritor.reescribirMensaje(widget, actual); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador implements SimpleSynchronousResourceReloadListener { + @Override + public ResourceLocation getFabricId() { + return new ResourceLocation(ID, "textos"); + } + + @Override + public void onResourceManagerReload(ResourceManager recursos) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + ResourceLocation ruta = new ResourceLocation(ID, "textos/" + variante + ".json"); + for (Resource recurso : recursos.getResourceStack(ruta)) { // de menor a mayor prioridad + try (Reader lector = recurso.openAsReader()) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.sourcePackId(), e); + } + } + if (!total.vacia()) { + break; + } + } + tabla = total; + LOG.info("ModpackES Runtime: {} textos en la tabla ({} plantillas)", total.tamano(), total.plantillas()); + } + } +} diff --git a/mod-runtime/fabric-1.20.1/src/main/resources/fabric.mod.json b/mod-runtime/fabric-1.20.1/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..41d694d --- /dev/null +++ b/mod-runtime/fabric-1.20.1/src/main/resources/fabric.mod.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "id": "${mod_id}", + "version": "${version}", + "name": "${mod_name}", + "description": "${mod_description}", + "authors": ["${mod_authors}"], + "contact": { + "sources": "https://github.com/Consterna/ModpackES" + }, + "license": "${mod_license}", + "environment": "client", + "entrypoints": { + "client": ["io.github.consterna.modpackes.ModpackESRuntime"] + }, + "depends": { + "fabricloader": ">=0.14.0", + "minecraft": "${minecraft_range}", + "java": ">=17", + "fabric-message-api-v1": "*", + "fabric-screen-api-v1": "*", + "fabric-item-api-v1": "*", + "fabric-resource-loader-v0": "*" + } +} diff --git a/mod-runtime/fabric-1.20.4/.gitignore b/mod-runtime/fabric-1.20.4/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/fabric-1.20.4/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/fabric-1.20.4/build.gradle b/mod-runtime/fabric-1.20.4/build.gradle new file mode 100644 index 0000000..5ce0d3e --- /dev/null +++ b/mod-runtime/fabric-1.20.4/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'net.fabricmc.fabric-loom-remap' version "${loom_version}" +} + +base { + archivesName = "modpackes_runtime-fabric-${project.minecraft_version}" +} + +version = project.mod_version +group = project.mod_group_id + +repositories { +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings loom.officialMojangMappings() + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +processResources { + def propiedades = [ + version : project.mod_version, + mod_id : project.mod_id, + mod_name : project.mod_name, + mod_license: project.mod_license, + mod_authors: project.mod_authors, + mod_description: project.mod_description, + minecraft_version: project.minecraft_version, + minecraft_range: project.findProperty('minecraft_range') ?: project.minecraft_version, + loader_version: project.loader_version, + ] + inputs.properties propiedades + filesMatching("fabric.mod.json") { + expand propiedades + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 17 // Minecraft 1.20.4 funciona con Java 17 + it.options.encoding = 'UTF-8' +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-plano/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-plano/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/fabric-1.20.4/gradle.properties b/mod-runtime/fabric-1.20.4/gradle.properties new file mode 100644 index 0000000..a1d0181 --- /dev/null +++ b/mod-runtime/fabric-1.20.4/gradle.properties @@ -0,0 +1,18 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +# Fabric API 0.97.3 para 1.20.4. Loom 1.17 corre con Java 21 (la 1.19 pide Java 25); el mod se compila a Java 17. +minecraft_version=1.20.4 +# Versiones en las que el mismo código compila y pasa las pruebas (comprobado con `construir_mod.py --propiedad`) +minecraft_range=>=1.20.3 <=1.20.4 +loader_version=0.15.1 +loom_version=1.17.21 +fabric_api_version=0.97.3+1.20.4 + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/fabric-1.20.4/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/fabric-1.20.4/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9fa7f28 Binary files /dev/null and b/mod-runtime/fabric-1.20.4/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/fabric-1.20.4/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/fabric-1.20.4/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f69664a --- /dev/null +++ b/mod-runtime/fabric-1.20.4/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip +networkTimeout=120000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/fabric-1.20.4/gradlew b/mod-runtime/fabric-1.20.4/gradlew new file mode 100644 index 0000000..249efbb --- /dev/null +++ b/mod-runtime/fabric-1.20.4/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/fabric-1.20.4/gradlew.bat b/mod-runtime/fabric-1.20.4/gradlew.bat new file mode 100644 index 0000000..3185a43 --- /dev/null +++ b/mod-runtime/fabric-1.20.4/gradlew.bat @@ -0,0 +1,112 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/mod-runtime/fabric-1.20.4/settings.gradle b/mod-runtime/fabric-1.20.4/settings.gradle new file mode 100644 index 0000000..604d9a6 --- /dev/null +++ b/mod-runtime/fabric-1.20.4/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'modpackes_runtime' diff --git a/mod-runtime/fabric-1.20.4/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/fabric-1.20.4/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..bfcb994 --- /dev/null +++ b/mod-runtime/fabric-1.20.4/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,135 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback; +import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.Screens; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes del juego (los de + * {@code tellraw}, los de los mods y la barra de acción) y tooltips cuyo texto sea exactamente uno de la tabla + * {@code assets/modpackes_runtime/textos/.json} que genera ModpackES (o encaje en una de sus plantillas con + * {@code %s} o {@code %d}). Si el juego no está en una variante de español, no hace nada. + * + *

También cambia el texto de los botones de una pantalla al abrirla. Fabric API no tiene evento para los + * tooltips que no son de objetos ni para el texto que una pantalla dibuja directamente: esos no se tocan. + * + *

El chat de otros jugadores no se toca: Fabric API no permite modificarlo, y no hace falta. + */ +public final class ModpackESRuntime implements ClientModInitializer { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + @Override + public void onInitializeClient() { + ResourceManagerHelper.get(PackType.CLIENT_RESOURCES).registerReloadListener(new Cargador()); + ClientReceiveMessageEvents.MODIFY_GAME.register(ModpackESRuntime::alRecibirMensaje); + ItemTooltipCallback.EVENT.register((pila, contexto, lineas) -> alCrearTooltip(lineas)); + ScreenEvents.AFTER_INIT.register((cliente, pantalla, ancho, alto) -> alIniciarPantalla(pantalla)); + } + + private static Component alRecibirMensaje(Component mensaje, boolean barraDeAccion) { + try { + Component nuevo = Reescritor.reescribir(mensaje, tabla); + return nuevo != null ? nuevo : mensaje; + } catch (RuntimeException | LinkageError e) { + avisar(e); + return mensaje; + } + } + + private static void alCrearTooltip(List lineas) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(Screen pantalla) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (AbstractWidget widget : Screens.getButtons(pantalla)) { + Reescritor.reescribirMensaje(widget, actual); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador implements SimpleSynchronousResourceReloadListener { + @Override + public ResourceLocation getFabricId() { + return new ResourceLocation(ID, "textos"); + } + + @Override + public void onResourceManagerReload(ResourceManager recursos) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + ResourceLocation ruta = new ResourceLocation(ID, "textos/" + variante + ".json"); + for (Resource recurso : recursos.getResourceStack(ruta)) { // de menor a mayor prioridad + try (Reader lector = recurso.openAsReader()) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.sourcePackId(), e); + } + } + if (!total.vacia()) { + break; + } + } + tabla = total; + LOG.info("ModpackES Runtime: {} textos en la tabla ({} plantillas)", total.tamano(), total.plantillas()); + } + } +} diff --git a/mod-runtime/fabric-1.20.4/src/main/resources/fabric.mod.json b/mod-runtime/fabric-1.20.4/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..41d694d --- /dev/null +++ b/mod-runtime/fabric-1.20.4/src/main/resources/fabric.mod.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "id": "${mod_id}", + "version": "${version}", + "name": "${mod_name}", + "description": "${mod_description}", + "authors": ["${mod_authors}"], + "contact": { + "sources": "https://github.com/Consterna/ModpackES" + }, + "license": "${mod_license}", + "environment": "client", + "entrypoints": { + "client": ["io.github.consterna.modpackes.ModpackESRuntime"] + }, + "depends": { + "fabricloader": ">=0.14.0", + "minecraft": "${minecraft_range}", + "java": ">=17", + "fabric-message-api-v1": "*", + "fabric-screen-api-v1": "*", + "fabric-item-api-v1": "*", + "fabric-resource-loader-v0": "*" + } +} diff --git a/mod-runtime/fabric-1.20.6/.gitignore b/mod-runtime/fabric-1.20.6/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/fabric-1.20.6/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/fabric-1.20.6/build.gradle b/mod-runtime/fabric-1.20.6/build.gradle new file mode 100644 index 0000000..e99ab98 --- /dev/null +++ b/mod-runtime/fabric-1.20.6/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'net.fabricmc.fabric-loom-remap' version "${loom_version}" +} + +base { + archivesName = "modpackes_runtime-fabric-${project.minecraft_version}" +} + +version = project.mod_version +group = project.mod_group_id + +repositories { +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings loom.officialMojangMappings() + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +processResources { + def propiedades = [ + version : project.mod_version, + mod_id : project.mod_id, + mod_name : project.mod_name, + mod_license: project.mod_license, + mod_authors: project.mod_authors, + mod_description: project.mod_description, + minecraft_version: project.minecraft_version, + minecraft_range: project.findProperty('minecraft_range') ?: project.minecraft_version, + loader_version: project.loader_version, + ] + inputs.properties propiedades + filesMatching("fabric.mod.json") { + expand propiedades + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 21 // Minecraft 1.20.6 funciona con Java 21 + it.options.encoding = 'UTF-8' +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-plano/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-plano/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/fabric-1.20.6/gradle.properties b/mod-runtime/fabric-1.20.6/gradle.properties new file mode 100644 index 0000000..d277e37 --- /dev/null +++ b/mod-runtime/fabric-1.20.6/gradle.properties @@ -0,0 +1,18 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +# Fabric API 0.100.8 para 1.20.6. Loom 1.17 corre con Java 21 (la 1.19 pide Java 25). +minecraft_version=1.20.6 +# Versiones en las que el mismo código compila y pasa las pruebas (comprobado con `construir_mod.py --propiedad`) +minecraft_range=>=1.20.5 <=1.20.6 +loader_version=0.15.6 +loom_version=1.17.21 +fabric_api_version=0.100.8+1.20.6 + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/fabric-1.20.6/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/fabric-1.20.6/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9fa7f28 Binary files /dev/null and b/mod-runtime/fabric-1.20.6/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/fabric-1.20.6/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/fabric-1.20.6/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f69664a --- /dev/null +++ b/mod-runtime/fabric-1.20.6/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip +networkTimeout=120000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/fabric-1.20.6/gradlew b/mod-runtime/fabric-1.20.6/gradlew new file mode 100644 index 0000000..249efbb --- /dev/null +++ b/mod-runtime/fabric-1.20.6/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/fabric-1.20.6/gradlew.bat b/mod-runtime/fabric-1.20.6/gradlew.bat new file mode 100644 index 0000000..3185a43 --- /dev/null +++ b/mod-runtime/fabric-1.20.6/gradlew.bat @@ -0,0 +1,112 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/mod-runtime/fabric-1.20.6/settings.gradle b/mod-runtime/fabric-1.20.6/settings.gradle new file mode 100644 index 0000000..604d9a6 --- /dev/null +++ b/mod-runtime/fabric-1.20.6/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'modpackes_runtime' diff --git a/mod-runtime/fabric-1.20.6/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/fabric-1.20.6/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..3787729 --- /dev/null +++ b/mod-runtime/fabric-1.20.6/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,135 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback; +import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.Screens; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes del juego (los de + * {@code tellraw}, los de los mods y la barra de acción) y tooltips cuyo texto sea exactamente uno de la tabla + * {@code assets/modpackes_runtime/textos/.json} que genera ModpackES (o encaje en una de sus plantillas con + * {@code %s} o {@code %d}). Si el juego no está en una variante de español, no hace nada. + * + *

También cambia el texto de los botones de una pantalla al abrirla. Fabric API no tiene evento para los + * tooltips que no son de objetos ni para el texto que una pantalla dibuja directamente: esos no se tocan. + * + *

El chat de otros jugadores no se toca: Fabric API no permite modificarlo, y no hace falta. + */ +public final class ModpackESRuntime implements ClientModInitializer { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + @Override + public void onInitializeClient() { + ResourceManagerHelper.get(PackType.CLIENT_RESOURCES).registerReloadListener(new Cargador()); + ClientReceiveMessageEvents.MODIFY_GAME.register(ModpackESRuntime::alRecibirMensaje); + ItemTooltipCallback.EVENT.register((pila, contexto, tipo, lineas) -> alCrearTooltip(lineas)); + ScreenEvents.AFTER_INIT.register((cliente, pantalla, ancho, alto) -> alIniciarPantalla(pantalla)); + } + + private static Component alRecibirMensaje(Component mensaje, boolean barraDeAccion) { + try { + Component nuevo = Reescritor.reescribir(mensaje, tabla); + return nuevo != null ? nuevo : mensaje; + } catch (RuntimeException | LinkageError e) { + avisar(e); + return mensaje; + } + } + + private static void alCrearTooltip(List lineas) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(Screen pantalla) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (AbstractWidget widget : Screens.getButtons(pantalla)) { + Reescritor.reescribirMensaje(widget, actual); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador implements SimpleSynchronousResourceReloadListener { + @Override + public ResourceLocation getFabricId() { + return new ResourceLocation(ID, "textos"); + } + + @Override + public void onResourceManagerReload(ResourceManager recursos) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + ResourceLocation ruta = new ResourceLocation(ID, "textos/" + variante + ".json"); + for (Resource recurso : recursos.getResourceStack(ruta)) { // de menor a mayor prioridad + try (Reader lector = recurso.openAsReader()) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.sourcePackId(), e); + } + } + if (!total.vacia()) { + break; + } + } + tabla = total; + LOG.info("ModpackES Runtime: {} textos en la tabla ({} plantillas)", total.tamano(), total.plantillas()); + } + } +} diff --git a/mod-runtime/fabric-1.20.6/src/main/resources/fabric.mod.json b/mod-runtime/fabric-1.20.6/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..0eb5416 --- /dev/null +++ b/mod-runtime/fabric-1.20.6/src/main/resources/fabric.mod.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "id": "${mod_id}", + "version": "${version}", + "name": "${mod_name}", + "description": "${mod_description}", + "authors": ["${mod_authors}"], + "contact": { + "sources": "https://github.com/Consterna/ModpackES" + }, + "license": "${mod_license}", + "environment": "client", + "entrypoints": { + "client": ["io.github.consterna.modpackes.ModpackESRuntime"] + }, + "depends": { + "fabricloader": ">=0.14.0", + "minecraft": "${minecraft_range}", + "java": ">=21", + "fabric-message-api-v1": "*", + "fabric-screen-api-v1": "*", + "fabric-item-api-v1": "*", + "fabric-resource-loader-v0": "*" + } +} diff --git a/mod-runtime/fabric-1.21.1/.gitignore b/mod-runtime/fabric-1.21.1/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/fabric-1.21.1/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/fabric-1.21.1/build.gradle b/mod-runtime/fabric-1.21.1/build.gradle new file mode 100644 index 0000000..324ab62 --- /dev/null +++ b/mod-runtime/fabric-1.21.1/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'net.fabricmc.fabric-loom-remap' version "${loom_version}" +} + +base { + archivesName = "modpackes_runtime-fabric-${project.minecraft_version}" +} + +version = project.mod_version +group = project.mod_group_id + +repositories { +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings loom.officialMojangMappings() + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +processResources { + def propiedades = [ + version : project.mod_version, + mod_id : project.mod_id, + mod_name : project.mod_name, + mod_license: project.mod_license, + mod_authors: project.mod_authors, + mod_description: project.mod_description, + minecraft_version: project.minecraft_version, + minecraft_range: project.findProperty('minecraft_range') ?: project.minecraft_version, + loader_version: project.loader_version, + ] + inputs.properties propiedades + filesMatching("fabric.mod.json") { + expand propiedades + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 21 // Minecraft 1.21.1 funciona con Java 21 + it.options.encoding = 'UTF-8' +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-plano/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-plano/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/fabric-1.21.1/gradle.properties b/mod-runtime/fabric-1.21.1/gradle.properties new file mode 100644 index 0000000..2e0483b --- /dev/null +++ b/mod-runtime/fabric-1.21.1/gradle.properties @@ -0,0 +1,18 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +# Fabric API 0.110.0 para 1.21.1 (compatible con cualquier Fabric API posterior de esa versión). Loom 1.17 corre con Java 21 (la 1.19 pide Java 25). +minecraft_version=1.21.1 +# Versiones en las que el mismo código compila y pasa las pruebas (comprobado con `construir_mod.py --propiedad`) +minecraft_range=>=1.21 <=1.21.1 +loader_version=0.15.11 +loom_version=1.17.21 +fabric_api_version=0.110.0+1.21.1 + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/fabric-1.21.1/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/fabric-1.21.1/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9fa7f28 Binary files /dev/null and b/mod-runtime/fabric-1.21.1/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/fabric-1.21.1/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/fabric-1.21.1/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f69664a --- /dev/null +++ b/mod-runtime/fabric-1.21.1/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip +networkTimeout=120000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/fabric-1.21.1/gradlew b/mod-runtime/fabric-1.21.1/gradlew new file mode 100644 index 0000000..249efbb --- /dev/null +++ b/mod-runtime/fabric-1.21.1/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/fabric-1.21.1/gradlew.bat b/mod-runtime/fabric-1.21.1/gradlew.bat new file mode 100644 index 0000000..3185a43 --- /dev/null +++ b/mod-runtime/fabric-1.21.1/gradlew.bat @@ -0,0 +1,112 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/mod-runtime/fabric-1.21.1/settings.gradle b/mod-runtime/fabric-1.21.1/settings.gradle new file mode 100644 index 0000000..604d9a6 --- /dev/null +++ b/mod-runtime/fabric-1.21.1/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'modpackes_runtime' diff --git a/mod-runtime/fabric-1.21.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/fabric-1.21.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..b6680a5 --- /dev/null +++ b/mod-runtime/fabric-1.21.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,135 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback; +import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.Screens; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes del juego (los de + * {@code tellraw}, los de los mods y la barra de acción) y tooltips cuyo texto sea exactamente uno de la tabla + * {@code assets/modpackes_runtime/textos/.json} que genera ModpackES (o encaje en una de sus plantillas con + * {@code %s} o {@code %d}). Si el juego no está en una variante de español, no hace nada. + * + *

También cambia el texto de los botones de una pantalla al abrirla. Fabric API no tiene evento para los + * tooltips que no son de objetos ni para el texto que una pantalla dibuja directamente: esos no se tocan. + * + *

El chat de otros jugadores no se toca: Fabric API no permite modificarlo, y no hace falta. + */ +public final class ModpackESRuntime implements ClientModInitializer { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + @Override + public void onInitializeClient() { + ResourceManagerHelper.get(PackType.CLIENT_RESOURCES).registerReloadListener(new Cargador()); + ClientReceiveMessageEvents.MODIFY_GAME.register(ModpackESRuntime::alRecibirMensaje); + ItemTooltipCallback.EVENT.register((pila, contexto, tipo, lineas) -> alCrearTooltip(lineas)); + ScreenEvents.AFTER_INIT.register((cliente, pantalla, ancho, alto) -> alIniciarPantalla(pantalla)); + } + + private static Component alRecibirMensaje(Component mensaje, boolean barraDeAccion) { + try { + Component nuevo = Reescritor.reescribir(mensaje, tabla); + return nuevo != null ? nuevo : mensaje; + } catch (RuntimeException | LinkageError e) { + avisar(e); + return mensaje; + } + } + + private static void alCrearTooltip(List lineas) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(Screen pantalla) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (AbstractWidget widget : Screens.getButtons(pantalla)) { + Reescritor.reescribirMensaje(widget, actual); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador implements SimpleSynchronousResourceReloadListener { + @Override + public ResourceLocation getFabricId() { + return ResourceLocation.fromNamespaceAndPath(ID, "textos"); + } + + @Override + public void onResourceManagerReload(ResourceManager recursos) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + ResourceLocation ruta = ResourceLocation.fromNamespaceAndPath(ID, "textos/" + variante + ".json"); + for (Resource recurso : recursos.getResourceStack(ruta)) { // de menor a mayor prioridad + try (Reader lector = recurso.openAsReader()) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.sourcePackId(), e); + } + } + if (!total.vacia()) { + break; + } + } + tabla = total; + LOG.info("ModpackES Runtime: {} textos en la tabla ({} plantillas)", total.tamano(), total.plantillas()); + } + } +} diff --git a/mod-runtime/fabric-1.21.1/src/main/resources/fabric.mod.json b/mod-runtime/fabric-1.21.1/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..0eb5416 --- /dev/null +++ b/mod-runtime/fabric-1.21.1/src/main/resources/fabric.mod.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "id": "${mod_id}", + "version": "${version}", + "name": "${mod_name}", + "description": "${mod_description}", + "authors": ["${mod_authors}"], + "contact": { + "sources": "https://github.com/Consterna/ModpackES" + }, + "license": "${mod_license}", + "environment": "client", + "entrypoints": { + "client": ["io.github.consterna.modpackes.ModpackESRuntime"] + }, + "depends": { + "fabricloader": ">=0.14.0", + "minecraft": "${minecraft_range}", + "java": ">=21", + "fabric-message-api-v1": "*", + "fabric-screen-api-v1": "*", + "fabric-item-api-v1": "*", + "fabric-resource-loader-v0": "*" + } +} diff --git a/mod-runtime/fabric-1.21.11/.gitignore b/mod-runtime/fabric-1.21.11/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/fabric-1.21.11/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/fabric-1.21.11/build.gradle b/mod-runtime/fabric-1.21.11/build.gradle new file mode 100644 index 0000000..1eb43cf --- /dev/null +++ b/mod-runtime/fabric-1.21.11/build.gradle @@ -0,0 +1,62 @@ +plugins { + id 'net.fabricmc.fabric-loom-remap' version "${loom_version}" +} + +base { + archivesName = "modpackes_runtime-fabric-${project.minecraft_version}" +} + +version = project.mod_version +group = project.mod_group_id + +repositories { +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings loom.officialMojangMappings() + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +processResources { + def propiedades = [ + version : project.mod_version, + mod_id : project.mod_id, + mod_name : project.mod_name, + mod_license: project.mod_license, + mod_authors: project.mod_authors, + mod_description: project.mod_description, + minecraft_version: project.minecraft_version, + loader_version: project.loader_version, + ] + inputs.properties propiedades + filesMatching("fabric.mod.json") { + expand propiedades + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 21 // Minecraft 1.21.11 funciona con Java 21 + it.options.encoding = 'UTF-8' +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-plano/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-plano-1215/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/fabric-1.21.11/gradle.properties b/mod-runtime/fabric-1.21.11/gradle.properties new file mode 100644 index 0000000..d6ceb07 --- /dev/null +++ b/mod-runtime/fabric-1.21.11/gradle.properties @@ -0,0 +1,16 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +# Fabric API 0.141.6 para 1.21.11 (compatible con cualquier Fabric API posterior de esa versión). Loom 1.17 corre con Java 21 (la 1.19 pide Java 25). +minecraft_version=1.21.11 +loader_version=0.17.3 +loom_version=1.17.21 +fabric_api_version=0.141.6+1.21.11 + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/fabric-1.21.11/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/fabric-1.21.11/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9fa7f28 Binary files /dev/null and b/mod-runtime/fabric-1.21.11/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/fabric-1.21.11/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/fabric-1.21.11/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f69664a --- /dev/null +++ b/mod-runtime/fabric-1.21.11/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip +networkTimeout=120000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/fabric-1.21.11/gradlew b/mod-runtime/fabric-1.21.11/gradlew new file mode 100644 index 0000000..249efbb --- /dev/null +++ b/mod-runtime/fabric-1.21.11/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/fabric-1.21.11/gradlew.bat b/mod-runtime/fabric-1.21.11/gradlew.bat new file mode 100644 index 0000000..3185a43 --- /dev/null +++ b/mod-runtime/fabric-1.21.11/gradlew.bat @@ -0,0 +1,112 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/mod-runtime/fabric-1.21.11/settings.gradle b/mod-runtime/fabric-1.21.11/settings.gradle new file mode 100644 index 0000000..604d9a6 --- /dev/null +++ b/mod-runtime/fabric-1.21.11/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'modpackes_runtime' diff --git a/mod-runtime/fabric-1.21.11/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/fabric-1.21.11/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..cbb3c52 --- /dev/null +++ b/mod-runtime/fabric-1.21.11/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,135 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback; +import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.Screens; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes del juego (los de + * {@code tellraw}, los de los mods y la barra de acción) y tooltips cuyo texto sea exactamente uno de la tabla + * {@code assets/modpackes_runtime/textos/.json} que genera ModpackES (o encaje en una de sus plantillas con + * {@code %s} o {@code %d}). Si el juego no está en una variante de español, no hace nada. + * + *

También cambia el texto de los botones de una pantalla al abrirla. Fabric API no tiene evento para los + * tooltips que no son de objetos ni para el texto que una pantalla dibuja directamente: esos no se tocan. + * + *

El chat de otros jugadores no se toca: Fabric API no permite modificarlo, y no hace falta. + */ +public final class ModpackESRuntime implements ClientModInitializer { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + @Override + public void onInitializeClient() { + ResourceManagerHelper.get(PackType.CLIENT_RESOURCES).registerReloadListener(new Cargador()); + ClientReceiveMessageEvents.MODIFY_GAME.register(ModpackESRuntime::alRecibirMensaje); + ItemTooltipCallback.EVENT.register((pila, contexto, tipo, lineas) -> alCrearTooltip(lineas)); + ScreenEvents.AFTER_INIT.register((cliente, pantalla, ancho, alto) -> alIniciarPantalla(pantalla)); + } + + private static Component alRecibirMensaje(Component mensaje, boolean barraDeAccion) { + try { + Component nuevo = Reescritor.reescribir(mensaje, tabla); + return nuevo != null ? nuevo : mensaje; + } catch (RuntimeException | LinkageError e) { + avisar(e); + return mensaje; + } + } + + private static void alCrearTooltip(List lineas) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(Screen pantalla) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (AbstractWidget widget : Screens.getButtons(pantalla)) { + Reescritor.reescribirMensaje(widget, actual); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador implements SimpleSynchronousResourceReloadListener { + @Override + public Identifier getFabricId() { + return Identifier.fromNamespaceAndPath(ID, "textos"); + } + + @Override + public void onResourceManagerReload(ResourceManager recursos) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + Identifier ruta = Identifier.fromNamespaceAndPath(ID, "textos/" + variante + ".json"); + for (Resource recurso : recursos.getResourceStack(ruta)) { // de menor a mayor prioridad + try (Reader lector = recurso.openAsReader()) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.sourcePackId(), e); + } + } + if (!total.vacia()) { + break; + } + } + tabla = total; + LOG.info("ModpackES Runtime: {} textos en la tabla ({} plantillas)", total.tamano(), total.plantillas()); + } + } +} diff --git a/mod-runtime/fabric-1.21.11/src/main/resources/fabric.mod.json b/mod-runtime/fabric-1.21.11/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..210482c --- /dev/null +++ b/mod-runtime/fabric-1.21.11/src/main/resources/fabric.mod.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "id": "${mod_id}", + "version": "${version}", + "name": "${mod_name}", + "description": "${mod_description}", + "authors": ["${mod_authors}"], + "contact": { + "sources": "https://github.com/Consterna/ModpackES" + }, + "license": "${mod_license}", + "environment": "client", + "entrypoints": { + "client": ["io.github.consterna.modpackes.ModpackESRuntime"] + }, + "depends": { + "fabricloader": ">=0.14.0", + "minecraft": "${minecraft_version}", + "java": ">=21", + "fabric-message-api-v1": "*", + "fabric-screen-api-v1": "*", + "fabric-item-api-v1": "*", + "fabric-resource-loader-v0": "*" + } +} diff --git a/mod-runtime/fabric-1.21.4/.gitignore b/mod-runtime/fabric-1.21.4/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/fabric-1.21.4/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/fabric-1.21.4/build.gradle b/mod-runtime/fabric-1.21.4/build.gradle new file mode 100644 index 0000000..7afcb4b --- /dev/null +++ b/mod-runtime/fabric-1.21.4/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'net.fabricmc.fabric-loom-remap' version "${loom_version}" +} + +base { + archivesName = "modpackes_runtime-fabric-${project.minecraft_version}" +} + +version = project.mod_version +group = project.mod_group_id + +repositories { +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings loom.officialMojangMappings() + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +processResources { + def propiedades = [ + version : project.mod_version, + mod_id : project.mod_id, + mod_name : project.mod_name, + mod_license: project.mod_license, + mod_authors: project.mod_authors, + mod_description: project.mod_description, + minecraft_version: project.minecraft_version, + minecraft_range: project.findProperty('minecraft_range') ?: project.minecraft_version, + loader_version: project.loader_version, + ] + inputs.properties propiedades + filesMatching("fabric.mod.json") { + expand propiedades + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 21 // Minecraft 1.21.4 funciona con Java 21 + it.options.encoding = 'UTF-8' +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-plano/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-plano/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/fabric-1.21.4/gradle.properties b/mod-runtime/fabric-1.21.4/gradle.properties new file mode 100644 index 0000000..6b6e1ff --- /dev/null +++ b/mod-runtime/fabric-1.21.4/gradle.properties @@ -0,0 +1,18 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +# Fabric API 0.110.5 para 1.21.4 (compatible con cualquier Fabric API posterior de esa versión). Loom 1.17 corre con Java 21 (la 1.19 pide Java 25). +minecraft_version=1.21.4 +# Versiones en las que el mismo código compila y pasa las pruebas (comprobado con `construir_mod.py --propiedad`) +minecraft_range=>=1.21.2 <=1.21.4 +loader_version=0.16.9 +loom_version=1.17.21 +fabric_api_version=0.110.5+1.21.4 + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/fabric-1.21.4/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/fabric-1.21.4/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9fa7f28 Binary files /dev/null and b/mod-runtime/fabric-1.21.4/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/fabric-1.21.4/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/fabric-1.21.4/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f69664a --- /dev/null +++ b/mod-runtime/fabric-1.21.4/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip +networkTimeout=120000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/fabric-1.21.4/gradlew b/mod-runtime/fabric-1.21.4/gradlew new file mode 100644 index 0000000..249efbb --- /dev/null +++ b/mod-runtime/fabric-1.21.4/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/fabric-1.21.4/gradlew.bat b/mod-runtime/fabric-1.21.4/gradlew.bat new file mode 100644 index 0000000..3185a43 --- /dev/null +++ b/mod-runtime/fabric-1.21.4/gradlew.bat @@ -0,0 +1,112 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/mod-runtime/fabric-1.21.4/settings.gradle b/mod-runtime/fabric-1.21.4/settings.gradle new file mode 100644 index 0000000..604d9a6 --- /dev/null +++ b/mod-runtime/fabric-1.21.4/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'modpackes_runtime' diff --git a/mod-runtime/fabric-1.21.4/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/fabric-1.21.4/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..b6680a5 --- /dev/null +++ b/mod-runtime/fabric-1.21.4/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,135 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback; +import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.Screens; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes del juego (los de + * {@code tellraw}, los de los mods y la barra de acción) y tooltips cuyo texto sea exactamente uno de la tabla + * {@code assets/modpackes_runtime/textos/.json} que genera ModpackES (o encaje en una de sus plantillas con + * {@code %s} o {@code %d}). Si el juego no está en una variante de español, no hace nada. + * + *

También cambia el texto de los botones de una pantalla al abrirla. Fabric API no tiene evento para los + * tooltips que no son de objetos ni para el texto que una pantalla dibuja directamente: esos no se tocan. + * + *

El chat de otros jugadores no se toca: Fabric API no permite modificarlo, y no hace falta. + */ +public final class ModpackESRuntime implements ClientModInitializer { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + @Override + public void onInitializeClient() { + ResourceManagerHelper.get(PackType.CLIENT_RESOURCES).registerReloadListener(new Cargador()); + ClientReceiveMessageEvents.MODIFY_GAME.register(ModpackESRuntime::alRecibirMensaje); + ItemTooltipCallback.EVENT.register((pila, contexto, tipo, lineas) -> alCrearTooltip(lineas)); + ScreenEvents.AFTER_INIT.register((cliente, pantalla, ancho, alto) -> alIniciarPantalla(pantalla)); + } + + private static Component alRecibirMensaje(Component mensaje, boolean barraDeAccion) { + try { + Component nuevo = Reescritor.reescribir(mensaje, tabla); + return nuevo != null ? nuevo : mensaje; + } catch (RuntimeException | LinkageError e) { + avisar(e); + return mensaje; + } + } + + private static void alCrearTooltip(List lineas) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(Screen pantalla) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (AbstractWidget widget : Screens.getButtons(pantalla)) { + Reescritor.reescribirMensaje(widget, actual); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador implements SimpleSynchronousResourceReloadListener { + @Override + public ResourceLocation getFabricId() { + return ResourceLocation.fromNamespaceAndPath(ID, "textos"); + } + + @Override + public void onResourceManagerReload(ResourceManager recursos) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + ResourceLocation ruta = ResourceLocation.fromNamespaceAndPath(ID, "textos/" + variante + ".json"); + for (Resource recurso : recursos.getResourceStack(ruta)) { // de menor a mayor prioridad + try (Reader lector = recurso.openAsReader()) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.sourcePackId(), e); + } + } + if (!total.vacia()) { + break; + } + } + tabla = total; + LOG.info("ModpackES Runtime: {} textos en la tabla ({} plantillas)", total.tamano(), total.plantillas()); + } + } +} diff --git a/mod-runtime/fabric-1.21.4/src/main/resources/fabric.mod.json b/mod-runtime/fabric-1.21.4/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..0eb5416 --- /dev/null +++ b/mod-runtime/fabric-1.21.4/src/main/resources/fabric.mod.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "id": "${mod_id}", + "version": "${version}", + "name": "${mod_name}", + "description": "${mod_description}", + "authors": ["${mod_authors}"], + "contact": { + "sources": "https://github.com/Consterna/ModpackES" + }, + "license": "${mod_license}", + "environment": "client", + "entrypoints": { + "client": ["io.github.consterna.modpackes.ModpackESRuntime"] + }, + "depends": { + "fabricloader": ">=0.14.0", + "minecraft": "${minecraft_range}", + "java": ">=21", + "fabric-message-api-v1": "*", + "fabric-screen-api-v1": "*", + "fabric-item-api-v1": "*", + "fabric-resource-loader-v0": "*" + } +} diff --git a/mod-runtime/fabric-1.21.5/.gitignore b/mod-runtime/fabric-1.21.5/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/fabric-1.21.5/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/fabric-1.21.5/build.gradle b/mod-runtime/fabric-1.21.5/build.gradle new file mode 100644 index 0000000..ea90dc5 --- /dev/null +++ b/mod-runtime/fabric-1.21.5/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'net.fabricmc.fabric-loom-remap' version "${loom_version}" +} + +base { + archivesName = "modpackes_runtime-fabric-${project.minecraft_version}" +} + +version = project.mod_version +group = project.mod_group_id + +repositories { +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings loom.officialMojangMappings() + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +processResources { + def propiedades = [ + version : project.mod_version, + mod_id : project.mod_id, + mod_name : project.mod_name, + mod_license: project.mod_license, + mod_authors: project.mod_authors, + mod_description: project.mod_description, + minecraft_version: project.minecraft_version, + minecraft_range: project.findProperty('minecraft_range') ?: project.minecraft_version, + loader_version: project.loader_version, + ] + inputs.properties propiedades + filesMatching("fabric.mod.json") { + expand propiedades + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 21 // Minecraft 1.21.5 funciona con Java 21 + it.options.encoding = 'UTF-8' +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-plano/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-plano-1215/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/fabric-1.21.5/gradle.properties b/mod-runtime/fabric-1.21.5/gradle.properties new file mode 100644 index 0000000..f8aa153 --- /dev/null +++ b/mod-runtime/fabric-1.21.5/gradle.properties @@ -0,0 +1,18 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +# Fabric API 0.128.2 para 1.21.5 (compatible con cualquier Fabric API posterior de esa versión). Loom 1.17 corre con Java 21 (la 1.19 pide Java 25). +minecraft_version=1.21.5 +# Versiones en las que el mismo código compila y pasa las pruebas (comprobado con `construir_mod.py --propiedad`) +minecraft_range=>=1.21.5 <=1.21.10 +loader_version=0.16.10 +loom_version=1.17.21 +fabric_api_version=0.128.2+1.21.5 + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/fabric-1.21.5/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/fabric-1.21.5/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9fa7f28 Binary files /dev/null and b/mod-runtime/fabric-1.21.5/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/fabric-1.21.5/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/fabric-1.21.5/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f69664a --- /dev/null +++ b/mod-runtime/fabric-1.21.5/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip +networkTimeout=120000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/fabric-1.21.5/gradlew b/mod-runtime/fabric-1.21.5/gradlew new file mode 100644 index 0000000..249efbb --- /dev/null +++ b/mod-runtime/fabric-1.21.5/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/fabric-1.21.5/gradlew.bat b/mod-runtime/fabric-1.21.5/gradlew.bat new file mode 100644 index 0000000..3185a43 --- /dev/null +++ b/mod-runtime/fabric-1.21.5/gradlew.bat @@ -0,0 +1,112 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/mod-runtime/fabric-1.21.5/settings.gradle b/mod-runtime/fabric-1.21.5/settings.gradle new file mode 100644 index 0000000..604d9a6 --- /dev/null +++ b/mod-runtime/fabric-1.21.5/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'modpackes_runtime' diff --git a/mod-runtime/fabric-1.21.5/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/fabric-1.21.5/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..b6680a5 --- /dev/null +++ b/mod-runtime/fabric-1.21.5/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,135 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback; +import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.Screens; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes del juego (los de + * {@code tellraw}, los de los mods y la barra de acción) y tooltips cuyo texto sea exactamente uno de la tabla + * {@code assets/modpackes_runtime/textos/.json} que genera ModpackES (o encaje en una de sus plantillas con + * {@code %s} o {@code %d}). Si el juego no está en una variante de español, no hace nada. + * + *

También cambia el texto de los botones de una pantalla al abrirla. Fabric API no tiene evento para los + * tooltips que no son de objetos ni para el texto que una pantalla dibuja directamente: esos no se tocan. + * + *

El chat de otros jugadores no se toca: Fabric API no permite modificarlo, y no hace falta. + */ +public final class ModpackESRuntime implements ClientModInitializer { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + @Override + public void onInitializeClient() { + ResourceManagerHelper.get(PackType.CLIENT_RESOURCES).registerReloadListener(new Cargador()); + ClientReceiveMessageEvents.MODIFY_GAME.register(ModpackESRuntime::alRecibirMensaje); + ItemTooltipCallback.EVENT.register((pila, contexto, tipo, lineas) -> alCrearTooltip(lineas)); + ScreenEvents.AFTER_INIT.register((cliente, pantalla, ancho, alto) -> alIniciarPantalla(pantalla)); + } + + private static Component alRecibirMensaje(Component mensaje, boolean barraDeAccion) { + try { + Component nuevo = Reescritor.reescribir(mensaje, tabla); + return nuevo != null ? nuevo : mensaje; + } catch (RuntimeException | LinkageError e) { + avisar(e); + return mensaje; + } + } + + private static void alCrearTooltip(List lineas) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(Screen pantalla) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (AbstractWidget widget : Screens.getButtons(pantalla)) { + Reescritor.reescribirMensaje(widget, actual); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador implements SimpleSynchronousResourceReloadListener { + @Override + public ResourceLocation getFabricId() { + return ResourceLocation.fromNamespaceAndPath(ID, "textos"); + } + + @Override + public void onResourceManagerReload(ResourceManager recursos) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + ResourceLocation ruta = ResourceLocation.fromNamespaceAndPath(ID, "textos/" + variante + ".json"); + for (Resource recurso : recursos.getResourceStack(ruta)) { // de menor a mayor prioridad + try (Reader lector = recurso.openAsReader()) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.sourcePackId(), e); + } + } + if (!total.vacia()) { + break; + } + } + tabla = total; + LOG.info("ModpackES Runtime: {} textos en la tabla ({} plantillas)", total.tamano(), total.plantillas()); + } + } +} diff --git a/mod-runtime/fabric-1.21.5/src/main/resources/fabric.mod.json b/mod-runtime/fabric-1.21.5/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..0eb5416 --- /dev/null +++ b/mod-runtime/fabric-1.21.5/src/main/resources/fabric.mod.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "id": "${mod_id}", + "version": "${version}", + "name": "${mod_name}", + "description": "${mod_description}", + "authors": ["${mod_authors}"], + "contact": { + "sources": "https://github.com/Consterna/ModpackES" + }, + "license": "${mod_license}", + "environment": "client", + "entrypoints": { + "client": ["io.github.consterna.modpackes.ModpackESRuntime"] + }, + "depends": { + "fabricloader": ">=0.14.0", + "minecraft": "${minecraft_range}", + "java": ">=21", + "fabric-message-api-v1": "*", + "fabric-screen-api-v1": "*", + "fabric-item-api-v1": "*", + "fabric-resource-loader-v0": "*" + } +} diff --git a/mod-runtime/fabric-26.1/.gitignore b/mod-runtime/fabric-26.1/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/fabric-26.1/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/fabric-26.1/build.gradle b/mod-runtime/fabric-26.1/build.gradle new file mode 100644 index 0000000..1573081 --- /dev/null +++ b/mod-runtime/fabric-26.1/build.gradle @@ -0,0 +1,62 @@ +plugins { + id 'net.fabricmc.fabric-loom' version "${loom_version}" +} + +base { + archivesName = "modpackes_runtime-fabric-${project.minecraft_version}" +} + +version = project.mod_version +group = project.mod_group_id + +repositories { +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + implementation "net.fabricmc:fabric-loader:${project.loader_version}" + implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +processResources { + def propiedades = [ + version : project.mod_version, + mod_id : project.mod_id, + mod_name : project.mod_name, + mod_license: project.mod_license, + mod_authors: project.mod_authors, + mod_description: project.mod_description, + minecraft_version: project.minecraft_version, + minecraft_range: project.findProperty('minecraft_range') ?: project.minecraft_version, + loader_version: project.loader_version, + ] + inputs.properties propiedades + filesMatching("fabric.mod.json") { + expand propiedades + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 25 // Minecraft 26.x pide Java 25 + it.options.encoding = 'UTF-8' +} + +java { + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-plano/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-plano-1215/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/fabric-26.1/gradle.properties b/mod-runtime/fabric-26.1/gradle.properties new file mode 100644 index 0000000..b3c516a --- /dev/null +++ b/mod-runtime/fabric-26.1/gradle.properties @@ -0,0 +1,18 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +# Fabric API 0.145.1 para 26.1. Desde 26.1 Minecraft no está ofuscado: Loom 1.19 sin remapeo y Java 25. +minecraft_version=26.1 +# Versiones en las que el mismo código compila y pasa las pruebas (comprobado con `construir_mod.py --propiedad`) +minecraft_range=>=26.1 <=26.3 +loader_version=0.18.4 +loom_version=1.19.4 +fabric_api_version=0.145.1+26.1 + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/fabric-26.1/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/fabric-26.1/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9fa7f28 Binary files /dev/null and b/mod-runtime/fabric-26.1/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/fabric-26.1/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/fabric-26.1/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f69664a --- /dev/null +++ b/mod-runtime/fabric-26.1/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip +networkTimeout=120000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/fabric-26.1/gradlew b/mod-runtime/fabric-26.1/gradlew new file mode 100644 index 0000000..249efbb --- /dev/null +++ b/mod-runtime/fabric-26.1/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/fabric-26.1/gradlew.bat b/mod-runtime/fabric-26.1/gradlew.bat new file mode 100644 index 0000000..3185a43 --- /dev/null +++ b/mod-runtime/fabric-26.1/gradlew.bat @@ -0,0 +1,112 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/mod-runtime/fabric-26.1/settings.gradle b/mod-runtime/fabric-26.1/settings.gradle new file mode 100644 index 0000000..604d9a6 --- /dev/null +++ b/mod-runtime/fabric-26.1/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'modpackes_runtime' diff --git a/mod-runtime/fabric-26.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/fabric-26.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..5d1d35c --- /dev/null +++ b/mod-runtime/fabric-26.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,135 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback; +import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.Screens; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes del juego (los de + * {@code tellraw}, los de los mods y la barra de acción) y tooltips cuyo texto sea exactamente uno de la tabla + * {@code assets/modpackes_runtime/textos/.json} que genera ModpackES (o encaje en una de sus plantillas con + * {@code %s} o {@code %d}). Si el juego no está en una variante de español, no hace nada. + * + *

También cambia el texto de los botones de una pantalla al abrirla. Fabric API no tiene evento para los + * tooltips que no son de objetos ni para el texto que una pantalla dibuja directamente: esos no se tocan. + * + *

El chat de otros jugadores no se toca: Fabric API no permite modificarlo, y no hace falta. + */ +public final class ModpackESRuntime implements ClientModInitializer { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + @Override + public void onInitializeClient() { + ResourceManagerHelper.get(PackType.CLIENT_RESOURCES).registerReloadListener(new Cargador()); + ClientReceiveMessageEvents.MODIFY_GAME.register(ModpackESRuntime::alRecibirMensaje); + ItemTooltipCallback.EVENT.register((pila, contexto, tipo, lineas) -> alCrearTooltip(lineas)); + ScreenEvents.AFTER_INIT.register((cliente, pantalla, ancho, alto) -> alIniciarPantalla(pantalla)); + } + + private static Component alRecibirMensaje(Component mensaje, boolean barraDeAccion) { + try { + Component nuevo = Reescritor.reescribir(mensaje, tabla); + return nuevo != null ? nuevo : mensaje; + } catch (RuntimeException | LinkageError e) { + avisar(e); + return mensaje; + } + } + + private static void alCrearTooltip(List lineas) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(Screen pantalla) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (AbstractWidget widget : Screens.getWidgets(pantalla)) { + Reescritor.reescribirMensaje(widget, actual); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador implements SimpleSynchronousResourceReloadListener { + @Override + public Identifier getFabricId() { + return Identifier.fromNamespaceAndPath(ID, "textos"); + } + + @Override + public void onResourceManagerReload(ResourceManager recursos) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + Identifier ruta = Identifier.fromNamespaceAndPath(ID, "textos/" + variante + ".json"); + for (Resource recurso : recursos.getResourceStack(ruta)) { // de menor a mayor prioridad + try (Reader lector = recurso.openAsReader()) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.sourcePackId(), e); + } + } + if (!total.vacia()) { + break; + } + } + tabla = total; + LOG.info("ModpackES Runtime: {} textos en la tabla ({} plantillas)", total.tamano(), total.plantillas()); + } + } +} diff --git a/mod-runtime/fabric-26.1/src/main/resources/fabric.mod.json b/mod-runtime/fabric-26.1/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..c6e02e9 --- /dev/null +++ b/mod-runtime/fabric-26.1/src/main/resources/fabric.mod.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "id": "${mod_id}", + "version": "${version}", + "name": "${mod_name}", + "description": "${mod_description}", + "authors": ["${mod_authors}"], + "contact": { + "sources": "https://github.com/Consterna/ModpackES" + }, + "license": "${mod_license}", + "environment": "client", + "entrypoints": { + "client": ["io.github.consterna.modpackes.ModpackESRuntime"] + }, + "depends": { + "fabricloader": ">=0.14.0", + "minecraft": "${minecraft_range}", + "java": ">=25", + "fabric-message-api-v1": "*", + "fabric-screen-api-v1": "*", + "fabric-item-api-v1": "*", + "fabric-resource-loader-v0": "*" + } +} diff --git a/mod-runtime/forge-1.16.5/.gitignore b/mod-runtime/forge-1.16.5/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/forge-1.16.5/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/forge-1.16.5/build.gradle b/mod-runtime/forge-1.16.5/build.gradle new file mode 100644 index 0000000..35cd078 --- /dev/null +++ b/mod-runtime/forge-1.16.5/build.gradle @@ -0,0 +1,89 @@ +plugins { + id 'eclipse' + id 'idea' + id 'net.minecraftforge.gradle' version '[6.0,6.2)' +} + +version = mod_version +group = mod_group_id + +base { + archivesName = "${mod_id}-forge-${minecraft_version}" +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(8) // Minecraft 1.16.5 corre con Java 8 + +minecraft { + mappings channel: mapping_channel, version: mapping_version + + runs { + client { + workingDirectory project.file('run') + property 'forge.logging.markers', 'REGISTRIES' + property 'forge.logging.console.level', 'debug' + mods { + "${mod_id}" { + source sourceSets.main + } + } + } + } +} + +repositories { + mavenCentral() + maven { url = 'https://libraries.minecraft.net/' } +} + +dependencies { + minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +tasks.named('processResources', ProcessResources).configure { + var replaceProperties = [ + minecraft_version: minecraft_version, minecraft_version_range: minecraft_version_range, + forge_version: forge_version, forge_version_range: forge_version_range, + loader_version_range: loader_version_range, + mod_id: mod_id, mod_name: mod_name, mod_license: mod_license, mod_version: mod_version, + mod_authors: mod_authors, mod_description: mod_description, + ] + inputs.properties replaceProperties + + filesMatching(['META-INF/mods.toml', 'pack.mcmeta']) { + expand replaceProperties + [project: project] + } +} + +tasks.named('jar', Jar).configure { + manifest { + attributes([ + 'Specification-Title' : mod_id, + 'Specification-Vendor' : mod_authors, + 'Specification-Version' : '1', + 'Implementation-Title' : project.name, + 'Implementation-Version' : project.jar.archiveVersion, + 'Implementation-Vendor' : mod_authors + ]) + } + + finalizedBy 'reobfJar' +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-1165/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-1165/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/forge-1.16.5/gradle.properties b/mod-runtime/forge-1.16.5/gradle.properties new file mode 100644 index 0000000..1c96c09 --- /dev/null +++ b/mod-runtime/forge-1.16.5/gradle.properties @@ -0,0 +1,18 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +minecraft_version=1.16.5 +minecraft_version_range=[1.16.5,1.17) +forge_version=36.2.42 +forge_version_range=[36,37) +loader_version_range=[36,37) +mapping_channel=official +mapping_version=1.16.5 + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/forge-1.16.5/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/forge-1.16.5/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..c1962a7 Binary files /dev/null and b/mod-runtime/forge-1.16.5/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/forge-1.16.5/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/forge-1.16.5/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..de5ba6d --- /dev/null +++ b/mod-runtime/forge-1.16.5/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=120000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/forge-1.16.5/gradlew b/mod-runtime/forge-1.16.5/gradlew new file mode 100644 index 0000000..aeb74cb --- /dev/null +++ b/mod-runtime/forge-1.16.5/gradlew @@ -0,0 +1,245 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/forge-1.16.5/gradlew.bat b/mod-runtime/forge-1.16.5/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/mod-runtime/forge-1.16.5/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mod-runtime/forge-1.16.5/settings.gradle b/mod-runtime/forge-1.16.5/settings.gradle new file mode 100644 index 0000000..93f1b80 --- /dev/null +++ b/mod-runtime/forge-1.16.5/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven { + name = 'MinecraftForge' + url = 'https://maven.minecraftforge.net/' + } + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0' +} \ No newline at end of file diff --git a/mod-runtime/forge-1.16.5/src/main/java/io/github/consterna/modpackes/Cliente.java b/mod-runtime/forge-1.16.5/src/main/java/io/github/consterna/modpackes/Cliente.java new file mode 100644 index 0000000..3f2bd5a --- /dev/null +++ b/mod-runtime/forge-1.16.5/src/main/java/io/github/consterna/modpackes/Cliente.java @@ -0,0 +1,149 @@ +package io.github.consterna.modpackes; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.widget.Widget; +import net.minecraft.client.resources.ReloadListener; +import net.minecraft.profiler.IProfiler; +import net.minecraft.resources.IReloadableResourceManager; +import net.minecraft.resources.IResource; +import net.minecraft.resources.IResourceManager; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.text.ITextComponent; +import net.minecraftforge.client.event.ClientChatReceivedEvent; +import net.minecraftforge.client.event.GuiScreenEvent; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.entity.player.ItemTooltipEvent; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; + +/** Lo que solo existe en el cliente: engancha los eventos y carga la tabla. Java 8, como Minecraft 1.16.5. */ +final class Cliente { + private static final Logger LOG = LogManager.getLogger(ModpackESRuntime.ID); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + private Cliente() { + } + + /** + * Se llama al construir el mod, antes de la primera recarga de recursos. En 1.16.5 no hay un evento para registrar + * recargas del cliente y {@code FMLClientSetupEvent} se dispara durante esa primera recarga: un listener + * registrado ahí queda fuera y la tabla no se cargaría hasta que el jugador recargara los recursos. + */ + static void iniciar() { + IResourceManager gestor = Minecraft.getInstance().getResourceManager(); + if (gestor instanceof IReloadableResourceManager) { + ((IReloadableResourceManager) gestor).registerReloadListener(new Cargador()); + } + MinecraftForge.EVENT_BUS.addListener(Cliente::alRecibirChat); + MinecraftForge.EVENT_BUS.addListener(Cliente::alCrearTooltip); + MinecraftForge.EVENT_BUS.addListener(Cliente::alIniciarPantalla); + } + + private static void alRecibirChat(ClientChatReceivedEvent evento) { + try { + ITextComponent nuevo = Reescritor.reescribir(evento.getMessage(), tabla); + if (nuevo != null) { + evento.setMessage(nuevo); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alCrearTooltip(ItemTooltipEvent evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + List lineas = evento.getToolTip(); + for (int i = 0; i < lineas.size(); i++) { + ITextComponent nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(GuiScreenEvent.InitGuiEvent.Post evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (Widget widget : evento.getWidgetList()) { + Reescritor.reescribirMensaje(widget, actual); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase(Locale.ROOT).startsWith("es_")) { + return Collections.emptyList(); + } + String codigo = codigoDeIdioma.toLowerCase(Locale.ROOT); + return new ArrayList<>(new LinkedHashSet<>(Arrays.asList(codigo, "es_mx", "es_es"))); + } + + private static final class Cargador extends ReloadListener { + @Override + protected Tabla prepare(IResourceManager recursos, IProfiler perfil) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + ResourceLocation ruta = new ResourceLocation(ModpackESRuntime.ID, "textos/" + variante + ".json"); + if (!recursos.hasResource(ruta)) { + continue; + } + try { + for (IResource recurso : recursos.getResources(ruta)) { // de menor a mayor prioridad + try (IResource r = recurso; + Reader lector = new InputStreamReader(r.getInputStream(), StandardCharsets.UTF_8)) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.getSourceName(), e); + } + } + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude listar {}", ruta, e); + } + if (!total.vacia()) { + break; + } + } + return total; + } + + @Override + protected void apply(Tabla nueva, IResourceManager recursos, IProfiler perfil) { + tabla = nueva; + LOG.info("ModpackES Runtime: {} textos en la tabla ({} plantillas)", nueva.tamano(), nueva.plantillas()); + } + } +} diff --git a/mod-runtime/forge-1.16.5/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/forge-1.16.5/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..843d54c --- /dev/null +++ b/mod-runtime/forge-1.16.5/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,32 @@ +package io.github.consterna.modpackes; + +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.fml.DistExecutor; +import net.minecraftforge.fml.ExtensionPoint; +import net.minecraftforge.fml.ModLoadingContext; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.network.FMLNetworkConstants; +import org.apache.commons.lang3.tuple.Pair; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes de chat y tooltips de + * objetos, y el texto de los botones de una pantalla al abrirla, cuyo texto sea exactamente uno de la tabla + * {@code assets/modpackes_runtime/textos/.json} que genera ModpackES (o encaje en una de sus plantillas + * con {@code %s} o {@code %d}). Si el juego no está en una variante de español, no hace nada. + * + *

Esta clase solo decide dónde se carga: todo lo del cliente está en {@link Cliente}, para que un servidor + * dedicado no cargue clases que no tiene. Minecraft 1.16.5 no tiene tooltips por elementos, así que los + * tooltips que no son de objetos no se tocan. + */ +@Mod(ModpackESRuntime.ID) +public final class ModpackESRuntime { + public static final String ID = "modpackes_runtime"; + + public ModpackESRuntime() { + // Que un servidor sin el mod (o un cliente sin él en un servidor) no marque error por esto + ModLoadingContext.get().registerExtensionPoint( + ExtensionPoint.DISPLAYTEST, + () -> Pair.of(() -> FMLNetworkConstants.IGNORESERVERONLY, (version, remoto) -> true)); + DistExecutor.safeRunWhenOn(Dist.CLIENT, () -> Cliente::iniciar); + } +} diff --git a/mod-runtime/forge-1.16.5/src/main/resources/META-INF/mods.toml b/mod-runtime/forge-1.16.5/src/main/resources/META-INF/mods.toml new file mode 100644 index 0000000..94577b0 --- /dev/null +++ b/mod-runtime/forge-1.16.5/src/main/resources/META-INF/mods.toml @@ -0,0 +1,26 @@ +modLoader="javafml" +loaderVersion="${loader_version_range}" +license="${mod_license}" +# Solo cliente: Forge no lo carga en servidores dedicados y un servidor sin el mod no marca error. +clientSideOnly=true + +[[mods]] +modId="${mod_id}" +version="${mod_version}" +displayName="${mod_name}" +authors="${mod_authors}" +description='''${mod_description}''' + +[[dependencies.${mod_id}]] + modId="forge" + mandatory=true + versionRange="${forge_version_range}" + ordering="NONE" + side="CLIENT" + +[[dependencies.${mod_id}]] + modId="minecraft" + mandatory=true + versionRange="${minecraft_version_range}" + ordering="NONE" + side="CLIENT" diff --git a/mod-runtime/forge-1.16.5/src/main/resources/pack.mcmeta b/mod-runtime/forge-1.16.5/src/main/resources/pack.mcmeta new file mode 100644 index 0000000..0575a4a --- /dev/null +++ b/mod-runtime/forge-1.16.5/src/main/resources/pack.mcmeta @@ -0,0 +1,8 @@ +{ + "pack": { + "description": { + "text": "${mod_name}" + }, + "pack_format": 6 + } +} diff --git a/mod-runtime/forge-1.18.2/.gitignore b/mod-runtime/forge-1.18.2/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/forge-1.18.2/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/forge-1.18.2/build.gradle b/mod-runtime/forge-1.18.2/build.gradle new file mode 100644 index 0000000..e707ba1 --- /dev/null +++ b/mod-runtime/forge-1.18.2/build.gradle @@ -0,0 +1,89 @@ +plugins { + id 'eclipse' + id 'idea' + id 'net.minecraftforge.gradle' version '[6.0,6.2)' +} + +version = mod_version +group = mod_group_id + +base { + archivesName = "${mod_id}-forge-${minecraft_version}" +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(17) + +minecraft { + mappings channel: mapping_channel, version: mapping_version + + runs { + client { + workingDirectory project.file('run') + property 'forge.logging.markers', 'REGISTRIES' + property 'forge.logging.console.level', 'debug' + mods { + "${mod_id}" { + source sourceSets.main + } + } + } + } +} + +repositories { + mavenCentral() + maven { url = 'https://libraries.minecraft.net/' } +} + +dependencies { + minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +tasks.named('processResources', ProcessResources).configure { + var replaceProperties = [ + minecraft_version: minecraft_version, minecraft_version_range: minecraft_version_range, + forge_version: forge_version, forge_version_range: forge_version_range, + loader_version_range: loader_version_range, + mod_id: mod_id, mod_name: mod_name, mod_license: mod_license, mod_version: mod_version, + mod_authors: mod_authors, mod_description: mod_description, + ] + inputs.properties replaceProperties + + filesMatching(['META-INF/mods.toml', 'pack.mcmeta']) { + expand replaceProperties + [project: project] + } +} + +tasks.named('jar', Jar).configure { + manifest { + attributes([ + 'Specification-Title' : mod_id, + 'Specification-Vendor' : mod_authors, + 'Specification-Version' : '1', + 'Implementation-Title' : project.name, + 'Implementation-Version' : project.jar.archiveVersion, + 'Implementation-Vendor' : mod_authors + ]) + } + + finalizedBy 'reobfJar' +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-texto/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-texto/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/forge-1.18.2/gradle.properties b/mod-runtime/forge-1.18.2/gradle.properties new file mode 100644 index 0000000..4995bfe --- /dev/null +++ b/mod-runtime/forge-1.18.2/gradle.properties @@ -0,0 +1,18 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +minecraft_version=1.18.2 +minecraft_version_range=[1.18.2,1.19) +forge_version=40.3.12 +forge_version_range=[40,41) +loader_version_range=[40,41) +mapping_channel=official +mapping_version=1.18.2 + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/forge-1.18.2/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/forge-1.18.2/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..c1962a7 Binary files /dev/null and b/mod-runtime/forge-1.18.2/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/forge-1.18.2/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/forge-1.18.2/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..de5ba6d --- /dev/null +++ b/mod-runtime/forge-1.18.2/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=120000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/forge-1.18.2/gradlew b/mod-runtime/forge-1.18.2/gradlew new file mode 100644 index 0000000..aeb74cb --- /dev/null +++ b/mod-runtime/forge-1.18.2/gradlew @@ -0,0 +1,245 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/forge-1.18.2/gradlew.bat b/mod-runtime/forge-1.18.2/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/mod-runtime/forge-1.18.2/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mod-runtime/forge-1.18.2/settings.gradle b/mod-runtime/forge-1.18.2/settings.gradle new file mode 100644 index 0000000..93f1b80 --- /dev/null +++ b/mod-runtime/forge-1.18.2/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven { + name = 'MinecraftForge' + url = 'https://maven.minecraftforge.net/' + } + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0' +} \ No newline at end of file diff --git a/mod-runtime/forge-1.18.2/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/forge-1.18.2/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..d0c0dcd --- /dev/null +++ b/mod-runtime/forge-1.18.2/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,160 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.SimplePreparableReloadListener; +import net.minecraft.util.profiling.ProfilerFiller; +import net.minecraftforge.client.event.ClientChatReceivedEvent; +import net.minecraftforge.client.event.RegisterClientReloadListenersEvent; +import net.minecraftforge.client.event.RenderTooltipEvent; +import net.minecraftforge.client.event.ScreenEvent; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.entity.player.ItemTooltipEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes de chat, de la barra de + * acción y tooltips cuyo texto sea exactamente uno de la tabla {@code assets/modpackes_runtime/textos/.json} + * que genera ModpackES. Si el juego no está en una variante de español, no hace nada. + * + *

También cambia el texto de los botones de una pantalla al abrirla y los tooltips que no son de objetos. El + * texto que una pantalla dibuja directamente no pasa por ningún evento y no se toca. + */ +@Mod(ModpackESRuntime.ID) +public final class ModpackESRuntime { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + public ModpackESRuntime() { + FMLJavaModLoadingContext.get().getModEventBus().addListener(ModpackESRuntime::alRegistrarRecargas); + MinecraftForge.EVENT_BUS.addListener(ModpackESRuntime::alRecibirChat); + MinecraftForge.EVENT_BUS.addListener(ModpackESRuntime::alCrearTooltip); + MinecraftForge.EVENT_BUS.addListener(ModpackESRuntime::alReunirTooltip); + MinecraftForge.EVENT_BUS.addListener(ModpackESRuntime::alIniciarPantalla); + } + + private static void alRegistrarRecargas(RegisterClientReloadListenersEvent evento) { + evento.registerReloadListener(new Cargador()); + } + + private static void alRecibirChat(ClientChatReceivedEvent evento) { + try { + Component nuevo = Reescritor.reescribir(evento.getMessage(), tabla); + if (nuevo != null) { + evento.setMessage(nuevo); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alCrearTooltip(ItemTooltipEvent evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + List lineas = evento.getToolTip(); + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alReunirTooltip(RenderTooltipEvent.GatherComponents evento) { + try { + Reescritor.reescribirElementos(evento.getTooltipElements(), tabla); + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(ScreenEvent.InitScreenEvent.Post evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (GuiEventListener hijo : evento.getListenersList()) { + if (hijo instanceof AbstractWidget widget) { + Reescritor.reescribirMensaje(widget, actual); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador extends SimplePreparableReloadListener { + @Override + protected Tabla prepare(ResourceManager recursos, ProfilerFiller perfil) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + ResourceLocation ruta = new ResourceLocation(ID, "textos/" + variante + ".json"); + if (!recursos.hasResource(ruta)) { + continue; + } + try { + for (Resource recurso : recursos.getResources(ruta)) { // de menor a mayor prioridad + try (Resource r = recurso; Reader lector = new InputStreamReader(r.getInputStream(), StandardCharsets.UTF_8)) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.getSourceName(), e); + } + } + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude listar {}", ruta, e); + } + if (!total.vacia()) { + break; + } + } + return total; + } + + @Override + protected void apply(Tabla nueva, ResourceManager recursos, ProfilerFiller perfil) { + tabla = nueva; + LOG.info("ModpackES Runtime: {} textos en la tabla", nueva.tamano()); + } + } +} diff --git a/mod-runtime/forge-1.18.2/src/main/resources/META-INF/mods.toml b/mod-runtime/forge-1.18.2/src/main/resources/META-INF/mods.toml new file mode 100644 index 0000000..94577b0 --- /dev/null +++ b/mod-runtime/forge-1.18.2/src/main/resources/META-INF/mods.toml @@ -0,0 +1,26 @@ +modLoader="javafml" +loaderVersion="${loader_version_range}" +license="${mod_license}" +# Solo cliente: Forge no lo carga en servidores dedicados y un servidor sin el mod no marca error. +clientSideOnly=true + +[[mods]] +modId="${mod_id}" +version="${mod_version}" +displayName="${mod_name}" +authors="${mod_authors}" +description='''${mod_description}''' + +[[dependencies.${mod_id}]] + modId="forge" + mandatory=true + versionRange="${forge_version_range}" + ordering="NONE" + side="CLIENT" + +[[dependencies.${mod_id}]] + modId="minecraft" + mandatory=true + versionRange="${minecraft_version_range}" + ordering="NONE" + side="CLIENT" diff --git a/mod-runtime/forge-1.18.2/src/main/resources/pack.mcmeta b/mod-runtime/forge-1.18.2/src/main/resources/pack.mcmeta new file mode 100644 index 0000000..a452264 --- /dev/null +++ b/mod-runtime/forge-1.18.2/src/main/resources/pack.mcmeta @@ -0,0 +1,8 @@ +{ + "pack": { + "description": { + "text": "${mod_name}" + }, + "pack_format": 8 + } +} diff --git a/mod-runtime/forge-1.19.2/.gitignore b/mod-runtime/forge-1.19.2/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/forge-1.19.2/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/forge-1.19.2/build.gradle b/mod-runtime/forge-1.19.2/build.gradle new file mode 100644 index 0000000..b3f830d --- /dev/null +++ b/mod-runtime/forge-1.19.2/build.gradle @@ -0,0 +1,89 @@ +plugins { + id 'eclipse' + id 'idea' + id 'net.minecraftforge.gradle' version '[6.0,6.2)' +} + +version = mod_version +group = mod_group_id + +base { + archivesName = "${mod_id}-forge-${minecraft_version}" +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(17) + +minecraft { + mappings channel: mapping_channel, version: mapping_version + + runs { + client { + workingDirectory project.file('run') + property 'forge.logging.markers', 'REGISTRIES' + property 'forge.logging.console.level', 'debug' + mods { + "${mod_id}" { + source sourceSets.main + } + } + } + } +} + +repositories { + mavenCentral() + maven { url = 'https://libraries.minecraft.net/' } +} + +dependencies { + minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +tasks.named('processResources', ProcessResources).configure { + var replaceProperties = [ + minecraft_version: minecraft_version, minecraft_version_range: minecraft_version_range, + forge_version: forge_version, forge_version_range: forge_version_range, + loader_version_range: loader_version_range, + mod_id: mod_id, mod_name: mod_name, mod_license: mod_license, mod_version: mod_version, + mod_authors: mod_authors, mod_description: mod_description, + ] + inputs.properties replaceProperties + + filesMatching(['META-INF/mods.toml', 'pack.mcmeta']) { + expand replaceProperties + [project: project] + } +} + +tasks.named('jar', Jar).configure { + manifest { + attributes([ + 'Specification-Title' : mod_id, + 'Specification-Vendor' : mod_authors, + 'Specification-Version' : '1', + 'Implementation-Title' : project.name, + 'Implementation-Version' : project.jar.archiveVersion, + 'Implementation-Vendor' : mod_authors + ]) + } + + finalizedBy 'reobfJar' +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-sin-reserva/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-sin-reserva/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/forge-1.19.2/gradle.properties b/mod-runtime/forge-1.19.2/gradle.properties new file mode 100644 index 0000000..e7bbf68 --- /dev/null +++ b/mod-runtime/forge-1.19.2/gradle.properties @@ -0,0 +1,18 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +minecraft_version=1.19.2 +minecraft_version_range=[1.19.2,1.19.3) +forge_version=43.5.2 +forge_version_range=[43,44) +loader_version_range=[43,44) +mapping_channel=official +mapping_version=1.19.2 + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/forge-1.19.2/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/forge-1.19.2/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..c1962a7 Binary files /dev/null and b/mod-runtime/forge-1.19.2/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/forge-1.19.2/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/forge-1.19.2/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..de5ba6d --- /dev/null +++ b/mod-runtime/forge-1.19.2/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=120000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/forge-1.19.2/gradlew b/mod-runtime/forge-1.19.2/gradlew new file mode 100644 index 0000000..aeb74cb --- /dev/null +++ b/mod-runtime/forge-1.19.2/gradlew @@ -0,0 +1,245 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/forge-1.19.2/gradlew.bat b/mod-runtime/forge-1.19.2/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/mod-runtime/forge-1.19.2/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mod-runtime/forge-1.19.2/settings.gradle b/mod-runtime/forge-1.19.2/settings.gradle new file mode 100644 index 0000000..93f1b80 --- /dev/null +++ b/mod-runtime/forge-1.19.2/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven { + name = 'MinecraftForge' + url = 'https://maven.minecraftforge.net/' + } + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0' +} \ No newline at end of file diff --git a/mod-runtime/forge-1.19.2/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/forge-1.19.2/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..1f56a8f --- /dev/null +++ b/mod-runtime/forge-1.19.2/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,151 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.SimplePreparableReloadListener; +import net.minecraft.util.profiling.ProfilerFiller; +import net.minecraftforge.client.event.ClientChatReceivedEvent; +import net.minecraftforge.client.event.RegisterClientReloadListenersEvent; +import net.minecraftforge.client.event.RenderTooltipEvent; +import net.minecraftforge.client.event.ScreenEvent; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.entity.player.ItemTooltipEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes de chat, de la barra de + * acción y tooltips cuyo texto sea exactamente uno de la tabla {@code assets/modpackes_runtime/textos/.json} + * que genera ModpackES. Si el juego no está en una variante de español, no hace nada. + * + *

También cambia el texto de los botones de una pantalla al abrirla y los tooltips que no son de objetos. El + * texto que una pantalla dibuja directamente no pasa por ningún evento y no se toca. + */ +@Mod(ModpackESRuntime.ID) +public final class ModpackESRuntime { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + public ModpackESRuntime() { + FMLJavaModLoadingContext.get().getModEventBus().addListener(ModpackESRuntime::alRegistrarRecargas); + MinecraftForge.EVENT_BUS.addListener(ModpackESRuntime::alRecibirChat); + MinecraftForge.EVENT_BUS.addListener(ModpackESRuntime::alCrearTooltip); + MinecraftForge.EVENT_BUS.addListener(ModpackESRuntime::alReunirTooltip); + MinecraftForge.EVENT_BUS.addListener(ModpackESRuntime::alIniciarPantalla); + } + + private static void alRegistrarRecargas(RegisterClientReloadListenersEvent evento) { + evento.registerReloadListener(new Cargador()); + } + + private static void alRecibirChat(ClientChatReceivedEvent evento) { + try { + Component nuevo = Reescritor.reescribir(evento.getMessage(), tabla); + if (nuevo != null) { + evento.setMessage(nuevo); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alCrearTooltip(ItemTooltipEvent evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + List lineas = evento.getToolTip(); + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alReunirTooltip(RenderTooltipEvent.GatherComponents evento) { + try { + Reescritor.reescribirElementos(evento.getTooltipElements(), tabla); + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(ScreenEvent.Init.Post evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (GuiEventListener hijo : evento.getListenersList()) { + if (hijo instanceof AbstractWidget widget) { + Reescritor.reescribirMensaje(widget, actual); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador extends SimplePreparableReloadListener { + @Override + protected Tabla prepare(ResourceManager recursos, ProfilerFiller perfil) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + ResourceLocation ruta = new ResourceLocation(ID, "textos/" + variante + ".json"); + for (Resource recurso : recursos.getResourceStack(ruta)) { // de menor a mayor prioridad + try (Reader lector = recurso.openAsReader()) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.sourcePackId(), e); + } + } + if (!total.vacia()) { + break; + } + } + return total; + } + + @Override + protected void apply(Tabla nueva, ResourceManager recursos, ProfilerFiller perfil) { + tabla = nueva; + LOG.info("ModpackES Runtime: {} textos en la tabla", nueva.tamano()); + } + } +} diff --git a/mod-runtime/forge-1.19.2/src/main/resources/META-INF/mods.toml b/mod-runtime/forge-1.19.2/src/main/resources/META-INF/mods.toml new file mode 100644 index 0000000..94577b0 --- /dev/null +++ b/mod-runtime/forge-1.19.2/src/main/resources/META-INF/mods.toml @@ -0,0 +1,26 @@ +modLoader="javafml" +loaderVersion="${loader_version_range}" +license="${mod_license}" +# Solo cliente: Forge no lo carga en servidores dedicados y un servidor sin el mod no marca error. +clientSideOnly=true + +[[mods]] +modId="${mod_id}" +version="${mod_version}" +displayName="${mod_name}" +authors="${mod_authors}" +description='''${mod_description}''' + +[[dependencies.${mod_id}]] + modId="forge" + mandatory=true + versionRange="${forge_version_range}" + ordering="NONE" + side="CLIENT" + +[[dependencies.${mod_id}]] + modId="minecraft" + mandatory=true + versionRange="${minecraft_version_range}" + ordering="NONE" + side="CLIENT" diff --git a/mod-runtime/forge-1.19.2/src/main/resources/pack.mcmeta b/mod-runtime/forge-1.19.2/src/main/resources/pack.mcmeta new file mode 100644 index 0000000..7ebeefc --- /dev/null +++ b/mod-runtime/forge-1.19.2/src/main/resources/pack.mcmeta @@ -0,0 +1,8 @@ +{ + "pack": { + "description": { + "text": "${mod_name}" + }, + "pack_format": 9 + } +} diff --git a/mod-runtime/forge-1.20.1/.gitignore b/mod-runtime/forge-1.20.1/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/forge-1.20.1/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/forge-1.20.1/build.gradle b/mod-runtime/forge-1.20.1/build.gradle new file mode 100644 index 0000000..62546de --- /dev/null +++ b/mod-runtime/forge-1.20.1/build.gradle @@ -0,0 +1,89 @@ +plugins { + id 'eclipse' + id 'idea' + id 'net.minecraftforge.gradle' version '[6.0,6.2)' +} + +version = mod_version +group = mod_group_id + +base { + archivesName = "${mod_id}-forge-${minecraft_version}" +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(17) + +minecraft { + mappings channel: mapping_channel, version: mapping_version + + runs { + client { + workingDirectory project.file('run') + property 'forge.logging.markers', 'REGISTRIES' + property 'forge.logging.console.level', 'debug' + mods { + "${mod_id}" { + source sourceSets.main + } + } + } + } +} + +repositories { + mavenCentral() + maven { url = 'https://libraries.minecraft.net/' } +} + +dependencies { + minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +tasks.named('processResources', ProcessResources).configure { + var replaceProperties = [ + minecraft_version: minecraft_version, minecraft_version_range: minecraft_version_range, + forge_version: forge_version, forge_version_range: forge_version_range, + loader_version_range: loader_version_range, + mod_id: mod_id, mod_name: mod_name, mod_license: mod_license, mod_version: mod_version, + mod_authors: mod_authors, mod_description: mod_description, + ] + inputs.properties replaceProperties + + filesMatching(['META-INF/mods.toml', 'pack.mcmeta']) { + expand replaceProperties + [project: project] + } +} + +tasks.named('jar', Jar).configure { + manifest { + attributes([ + 'Specification-Title' : mod_id, + 'Specification-Vendor' : mod_authors, + 'Specification-Version' : '1', + 'Implementation-Title' : project.name, + 'Implementation-Version' : project.jar.archiveVersion, + 'Implementation-Vendor' : mod_authors + ]) + } + + finalizedBy 'reobfJar' +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-literal/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-literal/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/forge-1.20.1/gradle.properties b/mod-runtime/forge-1.20.1/gradle.properties new file mode 100644 index 0000000..0883481 --- /dev/null +++ b/mod-runtime/forge-1.20.1/gradle.properties @@ -0,0 +1,18 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +minecraft_version=1.20.1 +minecraft_version_range=[1.20.1,1.20.2) +forge_version=47.4.0 +forge_version_range=[47,48) +loader_version_range=[47,48) +mapping_channel=official +mapping_version=1.20.1 + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/forge-1.20.1/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/forge-1.20.1/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..c1962a7 Binary files /dev/null and b/mod-runtime/forge-1.20.1/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/forge-1.20.1/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/forge-1.20.1/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..de5ba6d --- /dev/null +++ b/mod-runtime/forge-1.20.1/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=120000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/forge-1.20.1/gradlew b/mod-runtime/forge-1.20.1/gradlew new file mode 100644 index 0000000..aeb74cb --- /dev/null +++ b/mod-runtime/forge-1.20.1/gradlew @@ -0,0 +1,245 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/forge-1.20.1/gradlew.bat b/mod-runtime/forge-1.20.1/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/mod-runtime/forge-1.20.1/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mod-runtime/forge-1.20.1/settings.gradle b/mod-runtime/forge-1.20.1/settings.gradle new file mode 100644 index 0000000..93f1b80 --- /dev/null +++ b/mod-runtime/forge-1.20.1/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven { + name = 'MinecraftForge' + url = 'https://maven.minecraftforge.net/' + } + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0' +} \ No newline at end of file diff --git a/mod-runtime/forge-1.20.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/forge-1.20.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..1f56a8f --- /dev/null +++ b/mod-runtime/forge-1.20.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,151 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.SimplePreparableReloadListener; +import net.minecraft.util.profiling.ProfilerFiller; +import net.minecraftforge.client.event.ClientChatReceivedEvent; +import net.minecraftforge.client.event.RegisterClientReloadListenersEvent; +import net.minecraftforge.client.event.RenderTooltipEvent; +import net.minecraftforge.client.event.ScreenEvent; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.entity.player.ItemTooltipEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes de chat, de la barra de + * acción y tooltips cuyo texto sea exactamente uno de la tabla {@code assets/modpackes_runtime/textos/.json} + * que genera ModpackES. Si el juego no está en una variante de español, no hace nada. + * + *

También cambia el texto de los botones de una pantalla al abrirla y los tooltips que no son de objetos. El + * texto que una pantalla dibuja directamente no pasa por ningún evento y no se toca. + */ +@Mod(ModpackESRuntime.ID) +public final class ModpackESRuntime { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + public ModpackESRuntime() { + FMLJavaModLoadingContext.get().getModEventBus().addListener(ModpackESRuntime::alRegistrarRecargas); + MinecraftForge.EVENT_BUS.addListener(ModpackESRuntime::alRecibirChat); + MinecraftForge.EVENT_BUS.addListener(ModpackESRuntime::alCrearTooltip); + MinecraftForge.EVENT_BUS.addListener(ModpackESRuntime::alReunirTooltip); + MinecraftForge.EVENT_BUS.addListener(ModpackESRuntime::alIniciarPantalla); + } + + private static void alRegistrarRecargas(RegisterClientReloadListenersEvent evento) { + evento.registerReloadListener(new Cargador()); + } + + private static void alRecibirChat(ClientChatReceivedEvent evento) { + try { + Component nuevo = Reescritor.reescribir(evento.getMessage(), tabla); + if (nuevo != null) { + evento.setMessage(nuevo); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alCrearTooltip(ItemTooltipEvent evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + List lineas = evento.getToolTip(); + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alReunirTooltip(RenderTooltipEvent.GatherComponents evento) { + try { + Reescritor.reescribirElementos(evento.getTooltipElements(), tabla); + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(ScreenEvent.Init.Post evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (GuiEventListener hijo : evento.getListenersList()) { + if (hijo instanceof AbstractWidget widget) { + Reescritor.reescribirMensaje(widget, actual); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador extends SimplePreparableReloadListener { + @Override + protected Tabla prepare(ResourceManager recursos, ProfilerFiller perfil) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + ResourceLocation ruta = new ResourceLocation(ID, "textos/" + variante + ".json"); + for (Resource recurso : recursos.getResourceStack(ruta)) { // de menor a mayor prioridad + try (Reader lector = recurso.openAsReader()) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.sourcePackId(), e); + } + } + if (!total.vacia()) { + break; + } + } + return total; + } + + @Override + protected void apply(Tabla nueva, ResourceManager recursos, ProfilerFiller perfil) { + tabla = nueva; + LOG.info("ModpackES Runtime: {} textos en la tabla", nueva.tamano()); + } + } +} diff --git a/mod-runtime/forge-1.20.1/src/main/resources/META-INF/mods.toml b/mod-runtime/forge-1.20.1/src/main/resources/META-INF/mods.toml new file mode 100644 index 0000000..94577b0 --- /dev/null +++ b/mod-runtime/forge-1.20.1/src/main/resources/META-INF/mods.toml @@ -0,0 +1,26 @@ +modLoader="javafml" +loaderVersion="${loader_version_range}" +license="${mod_license}" +# Solo cliente: Forge no lo carga en servidores dedicados y un servidor sin el mod no marca error. +clientSideOnly=true + +[[mods]] +modId="${mod_id}" +version="${mod_version}" +displayName="${mod_name}" +authors="${mod_authors}" +description='''${mod_description}''' + +[[dependencies.${mod_id}]] + modId="forge" + mandatory=true + versionRange="${forge_version_range}" + ordering="NONE" + side="CLIENT" + +[[dependencies.${mod_id}]] + modId="minecraft" + mandatory=true + versionRange="${minecraft_version_range}" + ordering="NONE" + side="CLIENT" diff --git a/mod-runtime/forge-1.20.1/src/main/resources/pack.mcmeta b/mod-runtime/forge-1.20.1/src/main/resources/pack.mcmeta new file mode 100644 index 0000000..e22de93 --- /dev/null +++ b/mod-runtime/forge-1.20.1/src/main/resources/pack.mcmeta @@ -0,0 +1,8 @@ +{ + "pack": { + "description": { + "text": "${mod_name}" + }, + "pack_format": 15 + } +} diff --git a/mod-runtime/neoforge-1.21.1/.gitignore b/mod-runtime/neoforge-1.21.1/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/neoforge-1.21.1/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/neoforge-1.21.1/build.gradle b/mod-runtime/neoforge-1.21.1/build.gradle new file mode 100644 index 0000000..4c5ad49 --- /dev/null +++ b/mod-runtime/neoforge-1.21.1/build.gradle @@ -0,0 +1,85 @@ +plugins { + id 'java-library' + id 'net.neoforged.moddev' version '2.0.148' + id 'idea' +} + +tasks.named('wrapper', Wrapper).configure { + distributionType = Wrapper.DistributionType.BIN +} + +version = mod_version +group = mod_group_id + +base { + archivesName = "${mod_id}-neoforge-${minecraft_version}" +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) + +repositories { +} + +neoForge { + version = project.neo_version + + runs { + client { + client() + } + } + + mods { + "${mod_id}" { + sourceSet(sourceSets.main) + } + } + + unitTest { + enable() + testedMod = mods."${mod_id}" + } +} + +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) { + var replaceProperties = [ + minecraft_version : minecraft_version, + minecraft_version_range: minecraft_version_range, + neo_version : neo_version, + neo_version_minimo : neo_version_minimo, + loader_version_range : loader_version_range, + mod_id : mod_id, + mod_name : mod_name, + mod_license : mod_license, + mod_version : mod_version, + mod_authors : mod_authors, + mod_description : mod_description, + ] + inputs.properties replaceProperties + expand replaceProperties + from "src/main/templates" + into "build/generated/sources/modMetadata" +} +sourceSets.main.resources.srcDir generateModMetadata +neoForge.ideSyncTask generateModMetadata + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-plano/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-plano/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/neoforge-1.21.1/gradle.properties b/mod-runtime/neoforge-1.21.1/gradle.properties new file mode 100644 index 0000000..dfacda1 --- /dev/null +++ b/mod-runtime/neoforge-1.21.1/gradle.properties @@ -0,0 +1,17 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +minecraft_version=1.21.1 +# Versiones en las que el mismo código compila y pasa las pruebas (comprobado con `construir_mod.py --propiedad`) +minecraft_version_range=[1.21,1.21.4) +neo_version_minimo=21.0.0 +neo_version=21.1.265 +loader_version_range=[1,) + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/neoforge-1.21.1/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/neoforge-1.21.1/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/mod-runtime/neoforge-1.21.1/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/neoforge-1.21.1/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/neoforge-1.21.1/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e1a5ec8 --- /dev/null +++ b/mod-runtime/neoforge-1.21.1/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=120000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/neoforge-1.21.1/gradlew b/mod-runtime/neoforge-1.21.1/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/mod-runtime/neoforge-1.21.1/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/neoforge-1.21.1/gradlew.bat b/mod-runtime/neoforge-1.21.1/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/mod-runtime/neoforge-1.21.1/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mod-runtime/neoforge-1.21.1/settings.gradle b/mod-runtime/neoforge-1.21.1/settings.gradle new file mode 100644 index 0000000..a2d1087 --- /dev/null +++ b/mod-runtime/neoforge-1.21.1/settings.gradle @@ -0,0 +1,11 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven { + name = 'NeoForged' + url = 'https://maven.neoforged.net/releases' + } + } +} + +rootProject.name = 'modpackes_runtime' diff --git a/mod-runtime/neoforge-1.21.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/neoforge-1.21.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..ecf9f11 --- /dev/null +++ b/mod-runtime/neoforge-1.21.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,153 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.SimplePreparableReloadListener; +import net.minecraft.util.profiling.ProfilerFiller; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.client.event.ClientChatReceivedEvent; +import net.neoforged.neoforge.client.event.RegisterClientReloadListenersEvent; +import net.neoforged.neoforge.client.event.RenderTooltipEvent; +import net.neoforged.neoforge.client.event.ScreenEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.entity.player.ItemTooltipEvent; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes de chat, de la barra de + * acción y tooltips cuyo texto sea exactamente uno de la tabla {@code assets/modpackes_runtime/textos/.json} + * que genera ModpackES (o encaje en una de sus plantillas con {@code %s} o {@code %d}). Si el juego no está en una + * variante de español, no hace nada. + * + *

También cambia el texto de los botones de una pantalla al abrirla y los tooltips que no son de objetos. El + * texto que una pantalla dibuja directamente no pasa por ningún evento y no se toca. + */ +@Mod(value = ModpackESRuntime.ID, dist = Dist.CLIENT) +public final class ModpackESRuntime { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + public ModpackESRuntime(IEventBus busDelMod) { + busDelMod.addListener(ModpackESRuntime::alRegistrarRecargas); + NeoForge.EVENT_BUS.addListener(ModpackESRuntime::alRecibirChat); + NeoForge.EVENT_BUS.addListener(ModpackESRuntime::alCrearTooltip); + NeoForge.EVENT_BUS.addListener(ModpackESRuntime::alReunirTooltip); + NeoForge.EVENT_BUS.addListener(ModpackESRuntime::alIniciarPantalla); + } + + private static void alRegistrarRecargas(RegisterClientReloadListenersEvent evento) { + evento.registerReloadListener(new Cargador()); + } + + private static void alRecibirChat(ClientChatReceivedEvent evento) { + try { + Component nuevo = Reescritor.reescribir(evento.getMessage(), tabla); + if (nuevo != null) { + evento.setMessage(nuevo); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alCrearTooltip(ItemTooltipEvent evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + List lineas = evento.getToolTip(); + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alReunirTooltip(RenderTooltipEvent.GatherComponents evento) { + try { + Reescritor.reescribirElementos(evento.getTooltipElements(), tabla); + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(ScreenEvent.Init.Post evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (GuiEventListener hijo : evento.getListenersList()) { + if (hijo instanceof AbstractWidget widget) { + Reescritor.reescribirMensaje(widget, actual); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador extends SimplePreparableReloadListener { + @Override + protected Tabla prepare(ResourceManager recursos, ProfilerFiller perfil) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + ResourceLocation ruta = ResourceLocation.fromNamespaceAndPath(ID, "textos/" + variante + ".json"); + for (Resource recurso : recursos.getResourceStack(ruta)) { // de menor a mayor prioridad + try (Reader lector = recurso.openAsReader()) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.sourcePackId(), e); + } + } + if (!total.vacia()) { + break; + } + } + return total; + } + + @Override + protected void apply(Tabla nueva, ResourceManager recursos, ProfilerFiller perfil) { + tabla = nueva; + LOG.info("ModpackES Runtime: {} textos en la tabla ({} plantillas)", nueva.tamano(), nueva.plantillas()); + } + } +} diff --git a/mod-runtime/neoforge-1.21.1/src/main/templates/META-INF/neoforge.mods.toml b/mod-runtime/neoforge-1.21.1/src/main/templates/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..1eec55d --- /dev/null +++ b/mod-runtime/neoforge-1.21.1/src/main/templates/META-INF/neoforge.mods.toml @@ -0,0 +1,25 @@ +modLoader="javafml" +loaderVersion="${loader_version_range}" +license="${mod_license}" + +[[mods]] +modId="${mod_id}" +version="${mod_version}" +displayName="${mod_name}" +authors="${mod_authors}" +description='''${mod_description}''' + +# Solo cliente: la clase principal se declara con dist = CLIENT, así que un servidor no la carga. +[[dependencies.${mod_id}]] + modId="neoforge" + type="required" + versionRange="[${neo_version_minimo},)" + ordering="NONE" + side="CLIENT" + +[[dependencies.${mod_id}]] + modId="minecraft" + type="required" + versionRange="${minecraft_version_range}" + ordering="NONE" + side="CLIENT" diff --git a/mod-runtime/neoforge-1.21.11/.gitignore b/mod-runtime/neoforge-1.21.11/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/neoforge-1.21.11/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/neoforge-1.21.11/build.gradle b/mod-runtime/neoforge-1.21.11/build.gradle new file mode 100644 index 0000000..b5472d8 --- /dev/null +++ b/mod-runtime/neoforge-1.21.11/build.gradle @@ -0,0 +1,85 @@ +plugins { + id 'java-library' + id 'net.neoforged.moddev' version '2.0.148' + id 'idea' +} + +tasks.named('wrapper', Wrapper).configure { + distributionType = Wrapper.DistributionType.BIN +} + +version = mod_version +group = mod_group_id + +base { + archivesName = "${mod_id}-neoforge-${minecraft_version}" +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) + +repositories { +} + +neoForge { + version = project.neo_version + + runs { + client { + client() + } + } + + mods { + "${mod_id}" { + sourceSet(sourceSets.main) + } + } + + unitTest { + enable() + testedMod = mods."${mod_id}" + } +} + +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) { + var replaceProperties = [ + minecraft_version : minecraft_version, + minecraft_version_range: minecraft_version_range, + neo_version : neo_version, + neo_version_minimo : neo_version_minimo, + loader_version_range : loader_version_range, + mod_id : mod_id, + mod_name : mod_name, + mod_license : mod_license, + mod_version : mod_version, + mod_authors : mod_authors, + mod_description : mod_description, + ] + inputs.properties replaceProperties + expand replaceProperties + from "src/main/templates" + into "build/generated/sources/modMetadata" +} +sourceSets.main.resources.srcDir generateModMetadata +neoForge.ideSyncTask generateModMetadata + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-plano/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-plano-1215/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/neoforge-1.21.11/gradle.properties b/mod-runtime/neoforge-1.21.11/gradle.properties new file mode 100644 index 0000000..0862e5b --- /dev/null +++ b/mod-runtime/neoforge-1.21.11/gradle.properties @@ -0,0 +1,17 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +minecraft_version=1.21.11 +# Versiones en las que el mismo código compila y pasa las pruebas (comprobado con `construir_mod.py --propiedad`) +minecraft_version_range=[1.21.11] +neo_version_minimo=21.11.0 +neo_version=21.11.48 +loader_version_range=[1,) + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/neoforge-1.21.11/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/neoforge-1.21.11/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/mod-runtime/neoforge-1.21.11/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/neoforge-1.21.11/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/neoforge-1.21.11/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e1a5ec8 --- /dev/null +++ b/mod-runtime/neoforge-1.21.11/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=120000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/neoforge-1.21.11/gradlew b/mod-runtime/neoforge-1.21.11/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/mod-runtime/neoforge-1.21.11/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/neoforge-1.21.11/gradlew.bat b/mod-runtime/neoforge-1.21.11/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/mod-runtime/neoforge-1.21.11/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mod-runtime/neoforge-1.21.11/settings.gradle b/mod-runtime/neoforge-1.21.11/settings.gradle new file mode 100644 index 0000000..a2d1087 --- /dev/null +++ b/mod-runtime/neoforge-1.21.11/settings.gradle @@ -0,0 +1,11 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven { + name = 'NeoForged' + url = 'https://maven.neoforged.net/releases' + } + } +} + +rootProject.name = 'modpackes_runtime' diff --git a/mod-runtime/neoforge-1.21.11/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/neoforge-1.21.11/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..a68accd --- /dev/null +++ b/mod-runtime/neoforge-1.21.11/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,153 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.SimplePreparableReloadListener; +import net.minecraft.util.profiling.ProfilerFiller; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.client.event.ClientChatReceivedEvent; +import net.neoforged.neoforge.client.event.AddClientReloadListenersEvent; +import net.neoforged.neoforge.client.event.RenderTooltipEvent; +import net.neoforged.neoforge.client.event.ScreenEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.entity.player.ItemTooltipEvent; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes de chat, de la barra de + * acción y tooltips cuyo texto sea exactamente uno de la tabla {@code assets/modpackes_runtime/textos/.json} + * que genera ModpackES (o encaje en una de sus plantillas con {@code %s} o {@code %d}). Si el juego no está en una + * variante de español, no hace nada. + * + *

También cambia el texto de los botones de una pantalla al abrirla y los tooltips que no son de objetos. El + * texto que una pantalla dibuja directamente no pasa por ningún evento y no se toca. + */ +@Mod(value = ModpackESRuntime.ID, dist = Dist.CLIENT) +public final class ModpackESRuntime { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + public ModpackESRuntime(IEventBus busDelMod) { + busDelMod.addListener(ModpackESRuntime::alRegistrarRecargas); + NeoForge.EVENT_BUS.addListener(ModpackESRuntime::alRecibirChat); + NeoForge.EVENT_BUS.addListener(ModpackESRuntime::alCrearTooltip); + NeoForge.EVENT_BUS.addListener(ModpackESRuntime::alReunirTooltip); + NeoForge.EVENT_BUS.addListener(ModpackESRuntime::alIniciarPantalla); + } + + private static void alRegistrarRecargas(AddClientReloadListenersEvent evento) { + evento.addListener(Identifier.fromNamespaceAndPath(ID, "textos"), new Cargador()); + } + + private static void alRecibirChat(ClientChatReceivedEvent evento) { + try { + Component nuevo = Reescritor.reescribir(evento.getMessage(), tabla); + if (nuevo != null) { + evento.setMessage(nuevo); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alCrearTooltip(ItemTooltipEvent evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + List lineas = evento.getToolTip(); + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alReunirTooltip(RenderTooltipEvent.GatherComponents evento) { + try { + Reescritor.reescribirElementos(evento.getTooltipElements(), tabla); + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(ScreenEvent.Init.Post evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (GuiEventListener hijo : evento.getListenersList()) { + if (hijo instanceof AbstractWidget widget) { + Reescritor.reescribirMensaje(widget, actual); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador extends SimplePreparableReloadListener { + @Override + protected Tabla prepare(ResourceManager recursos, ProfilerFiller perfil) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + Identifier ruta = Identifier.fromNamespaceAndPath(ID, "textos/" + variante + ".json"); + for (Resource recurso : recursos.getResourceStack(ruta)) { // de menor a mayor prioridad + try (Reader lector = recurso.openAsReader()) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.sourcePackId(), e); + } + } + if (!total.vacia()) { + break; + } + } + return total; + } + + @Override + protected void apply(Tabla nueva, ResourceManager recursos, ProfilerFiller perfil) { + tabla = nueva; + LOG.info("ModpackES Runtime: {} textos en la tabla ({} plantillas)", nueva.tamano(), nueva.plantillas()); + } + } +} diff --git a/mod-runtime/neoforge-1.21.11/src/main/templates/META-INF/neoforge.mods.toml b/mod-runtime/neoforge-1.21.11/src/main/templates/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..1eec55d --- /dev/null +++ b/mod-runtime/neoforge-1.21.11/src/main/templates/META-INF/neoforge.mods.toml @@ -0,0 +1,25 @@ +modLoader="javafml" +loaderVersion="${loader_version_range}" +license="${mod_license}" + +[[mods]] +modId="${mod_id}" +version="${mod_version}" +displayName="${mod_name}" +authors="${mod_authors}" +description='''${mod_description}''' + +# Solo cliente: la clase principal se declara con dist = CLIENT, así que un servidor no la carga. +[[dependencies.${mod_id}]] + modId="neoforge" + type="required" + versionRange="[${neo_version_minimo},)" + ordering="NONE" + side="CLIENT" + +[[dependencies.${mod_id}]] + modId="minecraft" + type="required" + versionRange="${minecraft_version_range}" + ordering="NONE" + side="CLIENT" diff --git a/mod-runtime/neoforge-1.21.4/.gitignore b/mod-runtime/neoforge-1.21.4/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/neoforge-1.21.4/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/neoforge-1.21.4/build.gradle b/mod-runtime/neoforge-1.21.4/build.gradle new file mode 100644 index 0000000..4c5ad49 --- /dev/null +++ b/mod-runtime/neoforge-1.21.4/build.gradle @@ -0,0 +1,85 @@ +plugins { + id 'java-library' + id 'net.neoforged.moddev' version '2.0.148' + id 'idea' +} + +tasks.named('wrapper', Wrapper).configure { + distributionType = Wrapper.DistributionType.BIN +} + +version = mod_version +group = mod_group_id + +base { + archivesName = "${mod_id}-neoforge-${minecraft_version}" +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) + +repositories { +} + +neoForge { + version = project.neo_version + + runs { + client { + client() + } + } + + mods { + "${mod_id}" { + sourceSet(sourceSets.main) + } + } + + unitTest { + enable() + testedMod = mods."${mod_id}" + } +} + +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) { + var replaceProperties = [ + minecraft_version : minecraft_version, + minecraft_version_range: minecraft_version_range, + neo_version : neo_version, + neo_version_minimo : neo_version_minimo, + loader_version_range : loader_version_range, + mod_id : mod_id, + mod_name : mod_name, + mod_license : mod_license, + mod_version : mod_version, + mod_authors : mod_authors, + mod_description : mod_description, + ] + inputs.properties replaceProperties + expand replaceProperties + from "src/main/templates" + into "build/generated/sources/modMetadata" +} +sourceSets.main.resources.srcDir generateModMetadata +neoForge.ideSyncTask generateModMetadata + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-plano/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-plano/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/neoforge-1.21.4/gradle.properties b/mod-runtime/neoforge-1.21.4/gradle.properties new file mode 100644 index 0000000..4d77d3f --- /dev/null +++ b/mod-runtime/neoforge-1.21.4/gradle.properties @@ -0,0 +1,17 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +minecraft_version=1.21.4 +# Versiones en las que el mismo código compila y pasa las pruebas (comprobado con `construir_mod.py --propiedad`) +minecraft_version_range=[1.21.4] +neo_version_minimo=21.4.0 +neo_version=21.4.161 +loader_version_range=[1,) + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/neoforge-1.21.4/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/neoforge-1.21.4/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/mod-runtime/neoforge-1.21.4/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/neoforge-1.21.4/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/neoforge-1.21.4/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e1a5ec8 --- /dev/null +++ b/mod-runtime/neoforge-1.21.4/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=120000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/neoforge-1.21.4/gradlew b/mod-runtime/neoforge-1.21.4/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/mod-runtime/neoforge-1.21.4/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/neoforge-1.21.4/gradlew.bat b/mod-runtime/neoforge-1.21.4/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/mod-runtime/neoforge-1.21.4/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mod-runtime/neoforge-1.21.4/settings.gradle b/mod-runtime/neoforge-1.21.4/settings.gradle new file mode 100644 index 0000000..a2d1087 --- /dev/null +++ b/mod-runtime/neoforge-1.21.4/settings.gradle @@ -0,0 +1,11 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven { + name = 'NeoForged' + url = 'https://maven.neoforged.net/releases' + } + } +} + +rootProject.name = 'modpackes_runtime' diff --git a/mod-runtime/neoforge-1.21.4/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/neoforge-1.21.4/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..6e6ce26 --- /dev/null +++ b/mod-runtime/neoforge-1.21.4/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,153 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.SimplePreparableReloadListener; +import net.minecraft.util.profiling.ProfilerFiller; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.client.event.ClientChatReceivedEvent; +import net.neoforged.neoforge.client.event.AddClientReloadListenersEvent; +import net.neoforged.neoforge.client.event.RenderTooltipEvent; +import net.neoforged.neoforge.client.event.ScreenEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.entity.player.ItemTooltipEvent; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes de chat, de la barra de + * acción y tooltips cuyo texto sea exactamente uno de la tabla {@code assets/modpackes_runtime/textos/.json} + * que genera ModpackES (o encaje en una de sus plantillas con {@code %s} o {@code %d}). Si el juego no está en una + * variante de español, no hace nada. + * + *

También cambia el texto de los botones de una pantalla al abrirla y los tooltips que no son de objetos. El + * texto que una pantalla dibuja directamente no pasa por ningún evento y no se toca. + */ +@Mod(value = ModpackESRuntime.ID, dist = Dist.CLIENT) +public final class ModpackESRuntime { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + public ModpackESRuntime(IEventBus busDelMod) { + busDelMod.addListener(ModpackESRuntime::alRegistrarRecargas); + NeoForge.EVENT_BUS.addListener(ModpackESRuntime::alRecibirChat); + NeoForge.EVENT_BUS.addListener(ModpackESRuntime::alCrearTooltip); + NeoForge.EVENT_BUS.addListener(ModpackESRuntime::alReunirTooltip); + NeoForge.EVENT_BUS.addListener(ModpackESRuntime::alIniciarPantalla); + } + + private static void alRegistrarRecargas(AddClientReloadListenersEvent evento) { + evento.addListener(ResourceLocation.fromNamespaceAndPath(ID, "textos"), new Cargador()); + } + + private static void alRecibirChat(ClientChatReceivedEvent evento) { + try { + Component nuevo = Reescritor.reescribir(evento.getMessage(), tabla); + if (nuevo != null) { + evento.setMessage(nuevo); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alCrearTooltip(ItemTooltipEvent evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + List lineas = evento.getToolTip(); + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alReunirTooltip(RenderTooltipEvent.GatherComponents evento) { + try { + Reescritor.reescribirElementos(evento.getTooltipElements(), tabla); + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(ScreenEvent.Init.Post evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (GuiEventListener hijo : evento.getListenersList()) { + if (hijo instanceof AbstractWidget widget) { + Reescritor.reescribirMensaje(widget, actual); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador extends SimplePreparableReloadListener { + @Override + protected Tabla prepare(ResourceManager recursos, ProfilerFiller perfil) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + ResourceLocation ruta = ResourceLocation.fromNamespaceAndPath(ID, "textos/" + variante + ".json"); + for (Resource recurso : recursos.getResourceStack(ruta)) { // de menor a mayor prioridad + try (Reader lector = recurso.openAsReader()) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.sourcePackId(), e); + } + } + if (!total.vacia()) { + break; + } + } + return total; + } + + @Override + protected void apply(Tabla nueva, ResourceManager recursos, ProfilerFiller perfil) { + tabla = nueva; + LOG.info("ModpackES Runtime: {} textos en la tabla ({} plantillas)", nueva.tamano(), nueva.plantillas()); + } + } +} diff --git a/mod-runtime/neoforge-1.21.4/src/main/templates/META-INF/neoforge.mods.toml b/mod-runtime/neoforge-1.21.4/src/main/templates/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..1eec55d --- /dev/null +++ b/mod-runtime/neoforge-1.21.4/src/main/templates/META-INF/neoforge.mods.toml @@ -0,0 +1,25 @@ +modLoader="javafml" +loaderVersion="${loader_version_range}" +license="${mod_license}" + +[[mods]] +modId="${mod_id}" +version="${mod_version}" +displayName="${mod_name}" +authors="${mod_authors}" +description='''${mod_description}''' + +# Solo cliente: la clase principal se declara con dist = CLIENT, así que un servidor no la carga. +[[dependencies.${mod_id}]] + modId="neoforge" + type="required" + versionRange="[${neo_version_minimo},)" + ordering="NONE" + side="CLIENT" + +[[dependencies.${mod_id}]] + modId="minecraft" + type="required" + versionRange="${minecraft_version_range}" + ordering="NONE" + side="CLIENT" diff --git a/mod-runtime/neoforge-1.21.5/.gitignore b/mod-runtime/neoforge-1.21.5/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/neoforge-1.21.5/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/neoforge-1.21.5/build.gradle b/mod-runtime/neoforge-1.21.5/build.gradle new file mode 100644 index 0000000..b5472d8 --- /dev/null +++ b/mod-runtime/neoforge-1.21.5/build.gradle @@ -0,0 +1,85 @@ +plugins { + id 'java-library' + id 'net.neoforged.moddev' version '2.0.148' + id 'idea' +} + +tasks.named('wrapper', Wrapper).configure { + distributionType = Wrapper.DistributionType.BIN +} + +version = mod_version +group = mod_group_id + +base { + archivesName = "${mod_id}-neoforge-${minecraft_version}" +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) + +repositories { +} + +neoForge { + version = project.neo_version + + runs { + client { + client() + } + } + + mods { + "${mod_id}" { + sourceSet(sourceSets.main) + } + } + + unitTest { + enable() + testedMod = mods."${mod_id}" + } +} + +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) { + var replaceProperties = [ + minecraft_version : minecraft_version, + minecraft_version_range: minecraft_version_range, + neo_version : neo_version, + neo_version_minimo : neo_version_minimo, + loader_version_range : loader_version_range, + mod_id : mod_id, + mod_name : mod_name, + mod_license : mod_license, + mod_version : mod_version, + mod_authors : mod_authors, + mod_description : mod_description, + ] + inputs.properties replaceProperties + expand replaceProperties + from "src/main/templates" + into "build/generated/sources/modMetadata" +} +sourceSets.main.resources.srcDir generateModMetadata +neoForge.ideSyncTask generateModMetadata + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + +// La lógica común vive en ../comun: la tabla es de todos y el reescritor depende de cómo guarda el texto esa versión +sourceSets { + main.java.srcDir '../comun/tabla/src/main/java' + main.java.srcDir '../comun/reescritor-plano/src/main/java' + test.java.srcDir '../comun/tabla/src/test/java' + test.java.srcDir '../comun/reescritor-plano-1215/src/test/java' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/neoforge-1.21.5/gradle.properties b/mod-runtime/neoforge-1.21.5/gradle.properties new file mode 100644 index 0000000..e75f595 --- /dev/null +++ b/mod-runtime/neoforge-1.21.5/gradle.properties @@ -0,0 +1,17 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +minecraft_version=1.21.5 +# Versiones en las que el mismo código compila y pasa las pruebas (comprobado con `construir_mod.py --propiedad`) +minecraft_version_range=[1.21.5,1.21.11) +neo_version_minimo=21.5.0 +neo_version=21.5.101 +loader_version_range=[1,) + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/neoforge-1.21.5/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/neoforge-1.21.5/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/mod-runtime/neoforge-1.21.5/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod-runtime/neoforge-1.21.5/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/neoforge-1.21.5/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e1a5ec8 --- /dev/null +++ b/mod-runtime/neoforge-1.21.5/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=120000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/neoforge-1.21.5/gradlew b/mod-runtime/neoforge-1.21.5/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/mod-runtime/neoforge-1.21.5/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/neoforge-1.21.5/gradlew.bat b/mod-runtime/neoforge-1.21.5/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/mod-runtime/neoforge-1.21.5/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mod-runtime/neoforge-1.21.5/settings.gradle b/mod-runtime/neoforge-1.21.5/settings.gradle new file mode 100644 index 0000000..a2d1087 --- /dev/null +++ b/mod-runtime/neoforge-1.21.5/settings.gradle @@ -0,0 +1,11 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven { + name = 'NeoForged' + url = 'https://maven.neoforged.net/releases' + } + } +} + +rootProject.name = 'modpackes_runtime' diff --git a/mod-runtime/neoforge-1.21.5/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/neoforge-1.21.5/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..6e6ce26 --- /dev/null +++ b/mod-runtime/neoforge-1.21.5/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,153 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.SimplePreparableReloadListener; +import net.minecraft.util.profiling.ProfilerFiller; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.client.event.ClientChatReceivedEvent; +import net.neoforged.neoforge.client.event.AddClientReloadListenersEvent; +import net.neoforged.neoforge.client.event.RenderTooltipEvent; +import net.neoforged.neoforge.client.event.ScreenEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.entity.player.ItemTooltipEvent; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes de chat, de la barra de + * acción y tooltips cuyo texto sea exactamente uno de la tabla {@code assets/modpackes_runtime/textos/.json} + * que genera ModpackES (o encaje en una de sus plantillas con {@code %s} o {@code %d}). Si el juego no está en una + * variante de español, no hace nada. + * + *

También cambia el texto de los botones de una pantalla al abrirla y los tooltips que no son de objetos. El + * texto que una pantalla dibuja directamente no pasa por ningún evento y no se toca. + */ +@Mod(value = ModpackESRuntime.ID, dist = Dist.CLIENT) +public final class ModpackESRuntime { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + public ModpackESRuntime(IEventBus busDelMod) { + busDelMod.addListener(ModpackESRuntime::alRegistrarRecargas); + NeoForge.EVENT_BUS.addListener(ModpackESRuntime::alRecibirChat); + NeoForge.EVENT_BUS.addListener(ModpackESRuntime::alCrearTooltip); + NeoForge.EVENT_BUS.addListener(ModpackESRuntime::alReunirTooltip); + NeoForge.EVENT_BUS.addListener(ModpackESRuntime::alIniciarPantalla); + } + + private static void alRegistrarRecargas(AddClientReloadListenersEvent evento) { + evento.addListener(ResourceLocation.fromNamespaceAndPath(ID, "textos"), new Cargador()); + } + + private static void alRecibirChat(ClientChatReceivedEvent evento) { + try { + Component nuevo = Reescritor.reescribir(evento.getMessage(), tabla); + if (nuevo != null) { + evento.setMessage(nuevo); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alCrearTooltip(ItemTooltipEvent evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + List lineas = evento.getToolTip(); + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alReunirTooltip(RenderTooltipEvent.GatherComponents evento) { + try { + Reescritor.reescribirElementos(evento.getTooltipElements(), tabla); + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alIniciarPantalla(ScreenEvent.Init.Post evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + for (GuiEventListener hijo : evento.getListenersList()) { + if (hijo instanceof AbstractWidget widget) { + Reescritor.reescribirMensaje(widget, actual); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador extends SimplePreparableReloadListener { + @Override + protected Tabla prepare(ResourceManager recursos, ProfilerFiller perfil) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + ResourceLocation ruta = ResourceLocation.fromNamespaceAndPath(ID, "textos/" + variante + ".json"); + for (Resource recurso : recursos.getResourceStack(ruta)) { // de menor a mayor prioridad + try (Reader lector = recurso.openAsReader()) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.sourcePackId(), e); + } + } + if (!total.vacia()) { + break; + } + } + return total; + } + + @Override + protected void apply(Tabla nueva, ResourceManager recursos, ProfilerFiller perfil) { + tabla = nueva; + LOG.info("ModpackES Runtime: {} textos en la tabla ({} plantillas)", nueva.tamano(), nueva.plantillas()); + } + } +} diff --git a/mod-runtime/neoforge-1.21.5/src/main/templates/META-INF/neoforge.mods.toml b/mod-runtime/neoforge-1.21.5/src/main/templates/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..1eec55d --- /dev/null +++ b/mod-runtime/neoforge-1.21.5/src/main/templates/META-INF/neoforge.mods.toml @@ -0,0 +1,25 @@ +modLoader="javafml" +loaderVersion="${loader_version_range}" +license="${mod_license}" + +[[mods]] +modId="${mod_id}" +version="${mod_version}" +displayName="${mod_name}" +authors="${mod_authors}" +description='''${mod_description}''' + +# Solo cliente: la clase principal se declara con dist = CLIENT, así que un servidor no la carga. +[[dependencies.${mod_id}]] + modId="neoforge" + type="required" + versionRange="[${neo_version_minimo},)" + ordering="NONE" + side="CLIENT" + +[[dependencies.${mod_id}]] + modId="minecraft" + type="required" + versionRange="${minecraft_version_range}" + ordering="NONE" + side="CLIENT" diff --git a/scripts/construir_mod.py b/scripts/construir_mod.py new file mode 100644 index 0000000..ddf89c0 --- /dev/null +++ b/scripts/construir_mod.py @@ -0,0 +1,171 @@ +"""Compila el mod de apoyo (ModpackES Runtime) y deja el jar donde la aplicación lo busca. + +Uso: uv run python scripts/construir_mod.py [ | todos] [--sin-pruebas] + +Sin nombre compila todos. Cada uno necesita su JDK (Forge: 17, y 8 para compilar 1.16.5; NeoForge y Fabric: 21, +y Fabric 26.x: 25) y su Gradle, que se toman de JAVA_HOME (o JAVA_HOME__X64) y de `.herramientas/` si +existen, y acceso a internet la primera vez, para bajar el loader y Minecraft. El resultado queda en +`src/traductor_es/recursos/mods/`, que Git ignora. En GitHub Actions el jar lo construye el mismo comando +(`.github/workflows/ci.yml`). +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + +RAIZ = Path(__file__).resolve().parent.parent +DESTINO = RAIZ / "src" / "traductor_es" / "recursos" / "mods" +#: proyecto → (versión de Java, versión de Gradle) que pide su compilación +PROYECTOS = { + "forge-1.16.5": (17, "8.8"), + "forge-1.18.2": (17, "8.8"), + "forge-1.19.2": (17, "8.8"), + "forge-1.20.1": (17, "8.8"), + "neoforge-1.21.1": (21, "9.2.1"), + "neoforge-1.21.4": (21, "9.2.1"), + "neoforge-1.21.5": (21, "9.2.1"), + "neoforge-1.21.11": (21, "9.2.1"), + "fabric-1.19.4": (21, "9.8.2"), + "fabric-1.20.1": (21, "9.8.2"), + "fabric-1.20.4": (21, "9.8.2"), + "fabric-1.20.6": (21, "9.8.2"), + "fabric-1.21.1": (21, "9.8.2"), + "fabric-1.21.4": (21, "9.8.2"), + "fabric-1.21.5": (21, "9.8.2"), + "fabric-1.21.11": (21, "9.8.2"), + "fabric-26.1": (25, "9.8.2"), +} + + +def _herramientas() -> Path: + """`.herramientas` de este repositorio o, si se trabaja en un worktree, la del repositorio principal.""" + for carpeta in (RAIZ, *RAIZ.parents): + if (carpeta / ".herramientas").is_dir(): + return carpeta / ".herramientas" + return RAIZ / ".herramientas" + + +HERRAMIENTAS = _herramientas() + + +#: Proyectos cuyo código se compila con otro JDK que el que ejecuta Gradle (Forge 1.16.5 corre con Java 8). +TOOLCHAINS = {"forge-1.16.5": 8} + + +def _jdk(version: int) -> Path: + ejecutable = "java.exe" if os.name == "nt" else "java" + candidatos = [Path(os.environ[v]) for v in ("JAVA_HOME", f"JAVA_HOME_{version}_X64") if os.environ.get(v)] + candidatos += sorted(HERRAMIENTAS.glob(f"jdk-{version}*")) + sorted(HERRAMIENTAS.glob(f"jdk{version}u*")) + marca = '"1.8.' if version == 8 else f'"{version}.' # Java 8 se presenta como «1.8.0_x» + for c in candidatos: + if (c / "bin" / ejecutable).is_file() and marca in _version_de_java(c / "bin" / ejecutable): + return c + raise SystemExit(f"No encuentro un JDK {version}: define JAVA_HOME o ponlo en .herramientas/jdk-{version}*.") + + +def _version_de_java(java: Path) -> str: + return subprocess.run([str(java), "-version"], capture_output=True, text=True, check=False).stderr + + +def _gradle(proyecto: Path) -> list[str]: + envoltorio = proyecto / ("gradlew.bat" if os.name == "nt" else "gradlew") + local = ( + HERRAMIENTAS / f"gradle-{PROYECTOS[proyecto.name][1]}" / "bin" / ("gradle.bat" if os.name == "nt" else "gradle") + ) + return [str(local)] if local.is_file() else [str(envoltorio)] # el de .herramientas si está; si no, el del proyecto + + +#: Lo que dice Gradle cuando falla la red al bajar su distribución, el loader o Minecraft (y no el código del mod). +_FALLOS_DE_RED = ( + "Read timed out", "Connect timed out", "Connection reset", "Premature end of Content-Length", + "Server returned HTTP response code: 5", "Could not GET", "UnknownHostException", "Host desconocido", +) # fmt: skip +INTENTOS = 3 + + +def _ejecutar(orden: list[str], proyecto: Path, entorno: dict[str, str], espera: float = 20.0) -> int: + """Corre Gradle y repite hasta 3 veces solo si el fallo es de red; un error del código se devuelve a la primera.""" + codigo = 1 + for intento in range(1, INTENTOS + 1): + salida: list[str] = [] + with subprocess.Popen( + orden, cwd=proyecto, env=entorno, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, encoding="utf-8", errors="replace", + ) as proceso: # fmt: skip + assert proceso.stdout is not None + for linea in proceso.stdout: + sys.stdout.buffer.write(linea.encode("utf-8", "replace")) + sys.stdout.buffer.flush() + salida.append(linea) + codigo = proceso.returncode + texto = "".join(salida) + if codigo == 0 or intento == INTENTOS or not any(f in texto for f in _FALLOS_DE_RED): + return codigo + print(f"{proyecto.name}: falló la red; reintento {intento + 1} de {INTENTOS} en {espera:.0f} s.", flush=True) + time.sleep(espera) + return codigo + + +def construir(nombre: str, sin_pruebas: bool, propiedades: list[str] | None = None) -> int: + proyecto = RAIZ / "mod-runtime" / nombre + jdk = _jdk(PROYECTOS[nombre][0]) + entorno = {**os.environ, "JAVA_HOME": str(jdk), "PATH": str(jdk / "bin") + os.pathsep + os.environ["PATH"]} + hosts = HERRAMIENTAS / "hosts-java.txt" + if hosts.is_file(): + # Solo para Java y solo en esta compilación: ayuda si el DNS del equipo falla con algunos servidores. Va en + # JAVA_TOOL_OPTIONS porque así llega a todos los Java que se lanzan (cliente de Gradle, su proceso y ayudantes). + entorno["JAVA_TOOL_OPTIONS"] = f"{entorno.get('JAVA_TOOL_OPTIONS', '')} -Djdk.net.hosts.file={hosts}".strip() + if HERRAMIENTAS.is_dir(): # la caché de Gradle y del loader queda junto al proyecto y no en la carpeta del usuario + entorno.setdefault("GRADLE_USER_HOME", str(HERRAMIENTAS / "gradle-home")) + # Con propiedades (`-P`) solo se comprueba que el mismo código compila y pasa sus pruebas con otra versión + # de Minecraft: no se toca ningún jar. + orden = [*_gradle(proyecto), "test" if propiedades else "build", "--no-daemon", "--console=plain"] + orden += [f"-P{p}" for p in propiedades or []] + if nombre in TOOLCHAINS: # que Gradle encuentre el JDK de compilación sin bajarlo por su cuenta + orden.append(f"-Porg.gradle.java.installations.paths={_jdk(TOOLCHAINS[nombre])}") + if sin_pruebas: + orden += ["-x", "test"] + codigo = _ejecutar(orden, proyecto, entorno) + if codigo or propiedades: + return codigo + jars = sorted( + j + for j in (proyecto / "build" / "libs").glob("modpackes_runtime-*.jar") + if not j.stem.endswith(("-sources", "-dev")) + ) + if not jars: + print(f"{nombre}: la compilación terminó pero no hay jar en build/libs.", file=sys.stderr) + return 1 + DESTINO.mkdir(parents=True, exist_ok=True) + for viejo in DESTINO.glob(f"modpackes_runtime-{nombre}-*.jar"): + viejo.unlink() + final = DESTINO / jars[-1].name + shutil.copyfile(jars[-1], final) + print(f"Mod de apoyo listo: {final}") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("proyecto", nargs="?", default="todos", choices=[*PROYECTOS, "todos"]) + ap.add_argument("--sin-pruebas", action="store_true", help="compila sin correr las pruebas de Java") + ap.add_argument( + "--propiedad", action="append", metavar="CLAVE=VALOR", default=[], + help="prueba el mismo código con otra versión (p. ej. minecraft_version=1.21.8); no genera jar", + ) # fmt: skip + args = ap.parse_args() + elegidos = list(PROYECTOS) if args.proyecto == "todos" else [args.proyecto] + for nombre in elegidos: + if (codigo := construir(nombre, args.sin_pruebas, args.propiedad)) != 0: + return codigo + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/descargar_corpus.py b/scripts/descargar_corpus.py index c28a2f7..95aeb25 100644 --- a/scripts/descargar_corpus.py +++ b/scripts/descargar_corpus.py @@ -25,6 +25,41 @@ "technical-electrical": "https://cdn.modrinth.com/data/PmwWNPV6/versions/mkpGUYy6/Technical%20Electrical%20Temporal%20Totality-26.1.8.mrpack", "supernova-empires-2": "https://cdn.modrinth.com/data/Or9UFKR8/versions/ndm0obMO/Supernova%20Empires%202%207.0.3.mrpack", "prominence-2-fabric": "https://cdn.modrinth.com/data/EGs3lC8D/versions/ga7DDH29/Prominence%E2%84%A2%20II%20Hasturian%20Era%20v4.1.6.mrpack", + # Packs con datapacks, funciones, logros y scripts propios (para probar los datos de texto). Elegidos + # mirando el índice del .mrpack: el contenido de cada uno está en el comentario. + # 1.20.1 fabric · funciones, logros, datapacks .zip + "animatecraft-lankeren": "https://cdn.modrinth.com/data/rqoNUSYX/versions/iRG9ktbA/Lankeren%201.1.5-fixed.mrpack", + # 1.20.1 neoforge · kubejs y kubejs/data + "slimecrew": "https://cdn.modrinth.com/data/u3eBhKdv/versions/VHuCfVgS/SlimeCrew%20Frontier%201.3.0.mrpack", + # 1.20.1 forge · kubejs/data + "omnicraft-modpack": "https://cdn.modrinth.com/data/hcyfOf6Q/versions/Zh2npQSZ/Omnicraft%202.0.mrpack", + # 1.20.1 forge · kubejs y kubejs/data + "stormforge": "https://cdn.modrinth.com/data/Mclf6Juw/versions/Q1Znj0cU/Stormforge%202.1.7.mrpack", + # 1.21.1 neoforge · kubejs + "create-cobblemon-season-1": "https://cdn.modrinth.com/data/Mo3YEbmr/versions/KFjm9wO7/Create%20Cobblemon%20Season%201%201.0.2.mrpack", + # 1.20.1 fabric · logros, datapacks .zip + "prominence-2-chinese": "https://cdn.modrinth.com/data/2Vp3Sdd5/versions/ttpetMlI/Prominence%20II%20Hasturian%20Era%20CN%203.9.2.mrpack", + # 1.20.1 forge · funciones + "battlearmorytacz": "https://cdn.modrinth.com/data/rQiXwLhB/versions/TX0XBjIG/BattleArmory%20TACZ%2026.0.5.mrpack", + # 1.20.1 forge · datapacks .zip + "better-mc-forge-bmc4": "https://cdn.modrinth.com/data/4BV47HRn/versions/bdRIjRgs/Better%20MC%20%5BFORGE%5D%201.20.1%201.20.1%20v59.mrpack", + # 1.20.1 fabric · kubejs, datapacks .zip + "landscapes-reimagined-genesis": "https://cdn.modrinth.com/data/MdznSbKA/versions/pBrfi38A/Landscapes%20Reimagined%20Genesis%203.2.mrpack", + # Más versiones y loaders (1.16.5, 1.18.2, 1.19.2 Quilt, 1.20.1, 1.21.11 y 26.x): SNBT y formatos viejos + # 1.19.2 quilt · kubejs 171 / data 1297 / funciones 9 / logros 130 / zips 0 + "taidumcraft": "https://cdn.modrinth.com/data/qH6Phyax/versions/dmDeTjMb/TaidumCraft.mrpack", + # 26.1.2 fabric · kubejs 0 / data 0 / funciones 5 / logros 176 / zips 0 + "legacies": "https://cdn.modrinth.com/data/nWCaRO9f/versions/x4WpILDH/Legacies%2026-v4.1.mrpack", + # 1.16.5 forge · kubejs 125 / data 5 / funciones 0 / logros 0 / zips 0 + "techblock-skytech-3": "https://cdn.modrinth.com/data/jRXzxVGA/versions/IHN5lWsY/TechBlock%20-%20SkyTech%203%201.2.1.mrpack", + # 1.18.2 forge · kubejs 7 / data 0 / funciones 0 / logros 0 / zips 0 + "adventures-of-berk": "https://cdn.modrinth.com/data/zTK0MLqP/versions/yIog8kuK/Adventures%20of%20Berk%200.0.1.mrpack", + # 1.20.1 fabric · kubejs 3 / data 258 / funciones 0 / logros 123 / zips 0 + "spectral-creation": "https://cdn.modrinth.com/data/eqbP6Ycz/versions/iJhfeFZL/Spectral%20Creation%201.20.1-7.1.1.mrpack", + # 1.18.2 forge · kubejs 0 / data 0 / funciones 0 / logros 0 / zips 16 + "kings-dragons-of-berk": "https://cdn.modrinth.com/data/UmIII5vC/versions/b82038X3/Kings_dragon%201.0.0.mrpack", + # 1.21.11 fabric · kubejs 0 / data 0 / funciones 0 / logros 0 / zips 6 + "better-adventures-plus-plus": "https://cdn.modrinth.com/data/23niDfW7/versions/e18PwvTU/Better%20Adventures%2B%2B%201.3.mrpack", } #: Qué se baja de cada pack (rutas dentro del .mrpack). @@ -40,8 +75,18 @@ "overrides/kubejs/server_scripts/", "overrides/kubejs/startup_scripts/", "overrides/patchouli_books/", + # Datos de texto que ve el jugador: funciones, logros y datapacks, vengan de donde vengan + "overrides/kubejs/data/", + "overrides/global_packs/", + "overrides/datapacks/", + "overrides/config/openloader/", + "overrides/config/paxi/", ) EXTENSIONES_KUBEJS_ASSETS = ("/lang/",) # de kubejs/assets solo los idiomas +#: Otros sitios donde viven datapacks: la carpeta `resources/` de algunos packs (Legacies) y los de cada mundo. +#: Se baja solo la parte de datos de texto, no texturas ni el mundo entero. +DATOS_EN = {"overrides/resources/": ("/data/",), "overrides/saves/": ("/datapacks/",)} +EXTENSIONES_DE_DATOS = (".json", ".mcfunction", ".snbt", ".zip") LIMITE_POR_PACK = 40 * 1024 * 1024 @@ -111,7 +156,12 @@ def _elegida(nombre: str) -> bool: return False if nombre in EXACTOS or nombre.startswith(PREFIJOS): return True - return nombre.startswith("overrides/kubejs/assets/") and any(x in nombre for x in EXTENSIONES_KUBEJS_ASSETS) + if nombre.startswith(("overrides/kubejs/assets/", "overrides/resourcepacks/")) and nombre.endswith(".json"): + return any(x in nombre for x in (*EXTENSIONES_KUBEJS_ASSETS, "/tips/")) + for inicio, marcas in DATOS_EN.items(): + if nombre.startswith(inicio) and nombre.endswith(EXTENSIONES_DE_DATOS): + return any(m in nombre for m in marcas) + return False def _bajar(url: str, e: Entrada) -> bytes: @@ -151,9 +201,14 @@ def bajar_pack(slug: str, url: str) -> None: def main() -> int: elegidos = sys.argv[1:] or list(PACKS) + fallos = 0 for slug in elegidos: - bajar_pack(slug, PACKS[slug]) - return 0 + try: + bajar_pack(slug, PACKS[slug]) + except (RuntimeError, OSError) as e: # un pack que no se puede bajar no detiene a los demás + print(f"{slug}: no se bajó ({e})") + fallos += 1 + return 1 if fallos == len(elegidos) else 0 if __name__ == "__main__": diff --git a/src/traductor_es/__main__.py b/src/traductor_es/__main__.py index fd24e3e..1b08c88 100644 --- a/src/traductor_es/__main__.py +++ b/src/traductor_es/__main__.py @@ -9,7 +9,9 @@ from typing import Any, cast from . import NOMBRE, __version__, configuracion +from .fuentes.instancia import Instancia from .fuentes.lanzadores import buscar +from .modulos.cobertura import informe_en_json from .motores import fabrica from .motores.base import Cancelado, ErrorFatal, Motor from .motores.gemini import MODELO_POR_DEFECTO @@ -17,6 +19,7 @@ from .nucleo.dialectos import DIALECTOS from .proceso import GRUPOS, Informe, Proceso from .registro import LOGGER, configurar +from .salida import mod_apoyo def _escribir(texto: str) -> None: @@ -77,6 +80,16 @@ def comunes(p: argparse.ArgumentParser) -> None: p.add_argument("--lotes", action="store_true", help="usar la Batch API de Gemini (mitad de precio, tarda más)") p.add_argument("--contexto", default="", help="ambientación del modpack para el tono y los términos") p.add_argument("--revisar", action="store_true", help="revisión automática de misiones y libros (cuesta más)") + p.add_argument( + "--exhaustivo", + action="store_true", + help="en los scripts de KubeJS traduce toda frase, aunque no se sepa dónde se muestra (puede tocar lógica)", + ) + p.add_argument( + "--java", + action="store_true", + help="también el texto escrito en el código de los mods (lo muestra el mod de apoyo; ver instalar-runtime)", + ) comunes(sub.add_parser("analizar", help="cuenta lo que falta traducir y estima el costo (no escribe nada)")) traducir = sub.add_parser("traducir", help="traduce y escribe el resultado") @@ -85,6 +98,15 @@ def comunes(p: argparse.ArgumentParser) -> None: traducir.add_argument("--simular", action="store_true", help="prueba sin API: antepone «ES» a todo") traducir.add_argument("--no-activar", action="store_true", help="no tocar options.txt") traducir.add_argument("--no-idioma", action="store_true", help="no cambiar el idioma del juego") + cob = sub.add_parser("cobertura", help="dice qué texto queda en inglés y por qué (solo lee, no escribe nada)") + cob.add_argument("carpeta", help="carpeta de la instancia (o su carpeta minecraft)") + cob.add_argument("--java", action="store_true", help="también el texto escrito en el código de los mods (tarda)") + cob.add_argument("--json", action="store_true", help="escribe solo el informe, como JSON (UTF-8)") + rt = sub.add_parser( + "instalar-runtime", help="pone o quita el mod de apoyo que muestra el texto del código de los mods" + ) + rt.add_argument("carpeta", help="carpeta de la instancia (o su carpeta minecraft)") + rt.add_argument("--quitar", action="store_true", help="quita el mod de apoyo de mods/") restaurar = sub.add_parser("restaurar", help="devuelve misiones, scripts y libros propios a su original") restaurar.add_argument("carpeta") modelos = sub.add_parser("modelos", help="lista los modelos que ofrece el motor") @@ -106,6 +128,8 @@ def _traducir_o_analizar(args: argparse.Namespace) -> None: with Proceso( args.carpeta, motor, args.dialecto, grupos, registro=_escribir, hilos=getattr(args, "hilos", 4), usar_lotes=args.lotes, contexto=args.contexto, revisar=args.revisar, + texto_java=args.java, + scripts_exhaustivo=args.exhaustivo, ) as p: # fmt: skip _escribir(f"Minecraft {p.instancia.version} · {p.instancia.loader or '¿loader?'} · {p.instancia.juego}") if args.orden == "analizar": @@ -115,6 +139,28 @@ def _traducir_o_analizar(args: argparse.Namespace) -> None: _mostrar(informe, args) +def _instalar_runtime(args: argparse.Namespace) -> None: + with Instancia(args.carpeta) as inst: + if not args.quitar: + _escribir(f"Instalado: {mod_apoyo.instalar(inst)}") + return + quitados = mod_apoyo.quitar(inst) + _escribir("Quité: " + ", ".join(p.name for p in quitados) if quitados else "No había mod de apoyo instalado.") + + +def _cobertura(args: argparse.Namespace) -> None: + with Proceso(args.carpeta, Simulador(), registro=(lambda _m: None) if args.json else _escribir) as p: + if not args.json: + _escribir(f"Minecraft {p.instancia.version} · {p.instancia.loader or '¿loader?'} · {p.instancia.juego}") + hallazgos = p.que_queda(java=args.java) + if args.json: + _escribir(informe_en_json(hallazgos)) + return + for h in hallazgos: + _escribir(f"[{h.categoria}] {h}") + _escribir(f"{len(hallazgos)} hallazgos." if hallazgos else "No encontré texto que quede en inglés.") + + def main(argv: Sequence[str] | None = None) -> int: cast(Any, sys.stdout).reconfigure(encoding="utf-8", errors="replace") args = _argumentos().parse_args(argv) @@ -124,6 +170,10 @@ def main(argv: Sequence[str] | None = None) -> int: if args.orden == "instancias": for e in buscar(): _escribir(f"{e.lanzador}: {e.nombre}\n {e.juego}") + elif args.orden == "instalar-runtime": + _instalar_runtime(args) + elif args.orden == "cobertura": + _cobertura(args) elif args.orden == "clave": nombre = fabrica.MOTORES[args.motor] if args.accion == "guardar": diff --git a/src/traductor_es/formatos/comandos.py b/src/traductor_es/formatos/comandos.py new file mode 100644 index 0000000..0e41a01 --- /dev/null +++ b/src/traductor_es/formatos/comandos.py @@ -0,0 +1,47 @@ +"""Comandos de chat de Minecraft (`tellraw`, `title`, `say`, `msg`…): separa el texto que ve el jugador. + +Sirve para una línea de un `.mcfunction` y para la cadena que un script de KubeJS le pasa a `runCommand`. +Solo reconoce la forma clásica, con el texto como JSON (`tellraw @a {"text":"Hola"}`) o como texto suelto +(`say Hola`). Lo que tenga otra forma (SNBT, macros `$`) no se toca. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +_INICIO = re.compile(r"(?:^\s*/?|\brun\s+)(?Ptellraw|title|say|msg|tell|w)[ \t]+") +#: `@a`, `@a[distance=..5, tag=x]` (con espacios dentro de los corchetes) o un nombre. +_SELECTOR = re.compile(r"(?:@[a-z]+(?:\[[^\]]*\])?|\S+)[ \t]+") +_TITULO = re.compile(r"(?:title|subtitle|actionbar)[ \t]+") +_CON_SELECTOR = frozenset({"tellraw", "title", "msg", "tell", "w"}) + + +@dataclass(frozen=True, slots=True) +class Comando: + prefijo: str #: todo lo que va antes del texto (`execute as @a run tellraw @s `) + cuerpo: str #: lo que ve el jugador: un componente JSON o un texto suelto + json: bool #: el cuerpo es un componente JSON (tellraw, title) y no texto suelto (say, msg) + + +def analizar(texto: str) -> Comando | None: + """El comando de chat que contiene el texto, o None si no es de los que muestran algo al jugador.""" + inicio = _INICIO.search(texto) + if inicio is None: + return None + orden = inicio.group("orden") + pos = inicio.end() + if orden in _CON_SELECTOR: + selector = _SELECTOR.match(texto, pos) + if selector is None: + return None + pos = selector.end() + if orden == "title": + titulo = _TITULO.match(texto, pos) + if titulo is None: # title … times / clear / reset + return None + pos = titulo.end() + cuerpo = texto[pos:] + if not cuerpo.strip(): + return None + return Comando(texto[:pos], cuerpo, json=orden in ("tellraw", "title")) diff --git a/src/traductor_es/formatos/componente.py b/src/traductor_es/formatos/componente.py new file mode 100644 index 0000000..df018a3 --- /dev/null +++ b/src/traductor_es/formatos/componente.py @@ -0,0 +1,50 @@ +"""Texto de un componente de Minecraft, sea cual sea la versión: JSON o SNBT. + +- Hasta 1.21.4 los componentes (`tellraw`, `title`, títulos de logros…) son JSON: `{"text":"Hola"}`. +- Desde 1.21.5 se guardan como NBT y en los comandos se escriben en SNBT: `{text:"Hola"}` (Minecraft Wiki, + «Text component format»). Una cadena suelta entre comillas y las listas valen en los dos formatos. + +Solo se traduce lo que ve el jugador: los campos `text`, las cadenas sueltas y el contenido de `extra`. Los +colores, las claves `translate`, los `score`/`selector`/`nbt` (los resuelve el servidor) y las acciones quedan igual. +""" + +from __future__ import annotations + +from collections.abc import Mapping + +from ..nucleo.mascara import es_traducible +from . import json_texto, snbt + +#: Campos SNBT cuyo valor es texto visible: `text`, el contenido de `extra` y las cadenas sueltas (sin campo). +_CAMPOS_VISIBLES = frozenset({None, "text", "extra"}) + + +def _cadenas_snbt(cuerpo: str) -> list[snbt.Cadena] | None: + t = cuerpo.strip() + if not t or t[0] not in "{[\"'": + return None + try: + return [c for c in snbt.escanear(cuerpo) if c.campo in _CAMPOS_VISIBLES] + except ValueError: + return None + + +def unidades(cuerpo: str) -> list[str] | None: + """Los textos visibles del componente, o None si no se reconoce el formato (no se toca).""" + partes = json_texto.extraer(cuerpo, literal=True) + if partes is not None: + return [p for p in partes if es_traducible(p)] + cadenas = _cadenas_snbt(cuerpo) + return None if cadenas is None else [c.texto for c in cadenas if c.texto.strip() and es_traducible(c.texto)] + + +def reconstruir(cuerpo: str, traducciones: Mapping[str, str]) -> str: + """El componente con los textos traducidos; lo demás (formato, colores, espacios del SNBT) queda igual.""" + if json_texto.cargar(cuerpo, literal=True) is not None: + return json_texto.reconstruir(cuerpo, traducciones, literal=True) + cadenas = _cadenas_snbt(cuerpo) + if cadenas is None: + return cuerpo + return snbt.reemplazar( + cuerpo, cadenas, {c.inicio: traducciones[c.texto] for c in cadenas if c.texto in traducciones} + ) diff --git a/src/traductor_es/formatos/flujo_js.py b/src/traductor_es/formatos/flujo_js.py new file mode 100644 index 0000000..573ed59 --- /dev/null +++ b/src/traductor_es/formatos/flujo_js.py @@ -0,0 +1,237 @@ +"""Mensajes que un script arma por partes y manda después. + + let mensaje = 'Te has ganado ' + mensaje += efecto.name // «name» es un texto que ve el jugador + mensaje += global.nombres[clave] // «nombres» es una tabla de textos que ve el jugador + player.tell(mensaje) + +Aquí solo se lee el código para saber qué variables, miembros y tablas acaban en un mensaje. Decidir qué +cadenas se traducen es cosa del módulo de scripts. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass + +from .javascript import CadenaJs + +_IDENT = r"[A-Za-z_$][\w$]*" +_RUTA = rf"{_IDENT}(?:\.{_IDENT})*" +_AGREGADO = re.compile(rf"\b({_IDENT})\s*\+=\s*({_RUTA})(?:(\[[^\]\n]*\])(?:\.({_IDENT}))?)?") +_ASIGNADA = re.compile(rf"({_IDENT})\s*\+?=\s*$") +_CLAVE = re.compile(rf"(?:({_IDENT})|['\"]([^'\"\n]+)['\"])\s*:\s*$") +_VENTANA = 160 #: cuánto código hacia atrás se mira para saber a qué se asigna una cadena + + +@dataclass(frozen=True, slots=True) +class Flujo: + variables: frozenset[str] = frozenset() #: variables que se pasan a una llamada de texto: `tell(mensaje)` + miembros: frozenset[str] = frozenset() #: `mensaje += efecto.name` → «name» + tablas: frozenset[str] = frozenset() #: `mensaje += global.nombres[clave]` → «nombres» + + +def analizar(src: str, llamadas: str) -> Flujo: + """`llamadas`: expresión regular con las llamadas cuyo argumento es texto para el jugador.""" + pasadas = re.compile(rf"(?:{llamadas})\s*\(\s*({_IDENT})\s*[,)]") + variables = frozenset(pasadas.findall(src)) + if not variables: + return Flujo() + miembros: set[str] = set() + tablas: set[str] = set() + for variable, ruta, indice, miembro in _AGREGADO.findall(src): + if variable not in variables: + continue + ultimo = ruta.rsplit(".", 1)[-1] + if miembro: # `efectos[i].name`: el texto es el miembro + miembros.add(miembro) + elif indice: # `nombres[clave]`: el texto está en la tabla + tablas.add(ultimo) + elif "." in ruta: + miembros.add(ultimo) + return Flujo(variables, frozenset(miembros), frozenset(tablas)) + + +def asignada_a(src: str, cadena: CadenaJs) -> str | None: + """La variable a la que se asigna o se suma esta cadena (`msg = '…'`, `msg += '…'`).""" + m = _ASIGNADA.search(src[max(0, cadena.inicio - _VENTANA) : cadena.inicio]) + return m.group(1) if m else None + + +def clave_de(src: str, cadena: CadenaJs) -> str | None: + """La clave de objeto a la que pertenece esta cadena como valor (`name: '…'`).""" + m = _CLAVE.search(src[max(0, cadena.inicio - _VENTANA) : cadena.inicio]) + return (m.group(1) or m.group(2)) if m else None + + +def cuerpos_de_tabla(src: str, cadenas: Sequence[CadenaJs], tablas: Iterable[str]) -> list[tuple[int, int]]: + """Posiciones (inicio, fin) del contenido de las tablas `x.nombres = { … }` con esos nombres.""" + nombres = frozenset(tablas) + if not nombres: + return [] + en_cadena = {c.inicio: c.fin for c in cadenas} + cuerpos: list[tuple[int, int]] = [] + for m in re.finditer(rf"({_RUTA})\s*=\s*\{{", src): + if m.group(1).rsplit(".", 1)[-1] not in nombres: + continue + i, profundidad = m.end(), 1 + while i < len(src) and profundidad: + if i in en_cadena: + i = en_cadena[i] + continue + profundidad += {"{": 1, "}": -1}.get(src[i], 0) + i += 1 + cuerpos.append((m.end(), i)) + return cuerpos + + +# ---------------------------------------------------------------------------------------------- funciones de texto +#: Métodos que transforman un texto sin cambiar de qué se trata (el resultado sigue siendo ese texto o sus líneas). +_TRANSFORMA = frozenset({ + "split", "trim", "trimStart", "trimEnd", "replace", "replaceAll", "slice", "substring", "concat", "join", "map", + "filter", "forEach", "toString", "padStart", "padEnd", "repeat", +}) # fmt: skip +#: Si en la línea se compara o se busca el texto, es lógica: esa función no se toca. +_LOGICA = re.compile( + r"[!=]==?(?!>)|\.(?:includes|indexOf|startsWith|endsWith|match|test|search|equals|localeCompare)\s*\(|\bswitch\b|\bcase\b" +) +_FUNCIONES = ( + re.compile(rf"\bfunction\s+({_IDENT})\s*\(([^)]*)\)\s*\{{"), + re.compile( + rf"\b(?:const|let|var)\s+({_IDENT})\s*=\s*(?:async\s*)?(?:function\s*\w*\s*)?\(([^)]*)\)\s*(?:=>\s*)?\{{" + ), + re.compile(rf"\b(?:const|let|var)\s+({_IDENT})\s*=\s*({_IDENT})\s*=>\s*\{{"), +) +_ASIGNACION = re.compile(rf"\s*(?:(?:const|let|var)\s+)?({_IDENT})\s*(\+?=)(?!=)") +_CALLBACK = re.compile(rf"(?:\(\s*({_IDENT})\s*(?:,[^)]*)?\)|\b({_IDENT}))\s*=>|function\s*\(\s*({_IDENT})") + + +def _en_blanco(src: str, cadenas: Sequence[CadenaJs]) -> str: + """El código con las cadenas y los comentarios en blanco (mismas posiciones): solo queda lo que es código.""" + letras = list(src) + for c in cadenas: + for i in range(c.inicio, c.fin): + if letras[i] != "\n": + letras[i] = " " + limpio = "".join(letras) + return re.sub(r"//[^\n]*|/\*.*?\*/", lambda m: re.sub(r"[^\n]", " ", m.group(0)), limpio, flags=re.S) + + +def _cierre(limpio: str, abierta: int) -> int: + profundidad, i = 1, abierta + while i < len(limpio) and profundidad: + profundidad += {"{": 1, "}": -1}.get(limpio[i], 0) + i += 1 + return i if profundidad == 0 else -1 + + +def _linea(cuerpo: str, pos: int) -> tuple[int, str]: + inicio = cuerpo.rfind("\n", 0, pos) + 1 + fin = cuerpo.find("\n", pos) + return inicio, cuerpo[inicio : len(cuerpo) if fin < 0 else fin] + + +def _tomar_callbacks(trozo: str, base: int, vistos: set[str], pendientes: list[str], declaraciones: set[int]) -> None: + """`texto.map(l => …)`: el parámetro del callback también es texto y se sigue; su declaración no es un uso.""" + for cb in _CALLBACK.finditer(trozo): + grupo = 1 if cb.group(1) else 2 if cb.group(2) else 3 + nuevo = cb.group(grupo) + if nuevo: + declaraciones.add(base + cb.start(grupo)) + if nuevo not in vistos: + vistos.add(nuevo) + pendientes.append(nuevo) + + +def _uso_permitido( + cuerpo: str, nombre: str, m: re.Match[str], vistos: set[str], pendientes: list[str], declaraciones: set[int] +) -> bool: + """Un uso de un nombre de texto que no es mostrarlo directamente: ¿es solo derivar otro texto de él?""" + inicio_linea, linea = _linea(cuerpo, m.start()) + if _LOGICA.search(linea): + return False + asignacion = _ASIGNACION.match(linea) + if asignacion: + if asignacion.start(1) + inicio_linea <= m.start() < asignacion.end(1) + inicio_linea: + return True # es el lado izquierdo: se vuelve a asignar + derivado = asignacion.group(1) + if derivado not in vistos: + vistos.add(derivado) + pendientes.append(derivado) + _tomar_callbacks(linea[m.end() - inicio_linea :], m.end(), vistos, pendientes, declaraciones) + return True + metodo = re.match(rf"\s*{re.escape(nombre)}\s*\.\s*(\w+)\s*\(", linea) + if metodo and metodo.group(1) in _TRANSFORMA and metodo.start() + inicio_linea <= m.start(): + _tomar_callbacks(linea[metodo.end() :], inicio_linea + metodo.end(), vistos, pendientes, declaraciones) + return True + return False + + +def _parametro_es_solo_texto( + cuerpo: str, + parametro: str, + patron: str, + plantillas: list[tuple[CadenaJs, set[str]]], + es_sumidero: Callable[[CadenaJs], bool], +) -> bool: + """Todos los usos del parámetro (y de lo derivado) son mostrarlo: nunca se compara, busca, guarda ni devuelve.""" + sumidero_antes = re.compile(rf"(?:{patron})\s*\(\s*$") + pendientes, vistos, sumideros = [parametro], {parametro}, 0 + declaraciones: set[int] = set() # dónde se declara el parámetro de un callback (`l => …`): no es un uso + while pendientes: + nombre = pendientes.pop() + # un `${nombre}` dentro de una plantilla vale solo si la plantilla se muestra tal cual + for c, nombres in plantillas: + if nombre in nombres and not es_sumidero(c): + return False + sumideros += nombre in nombres + for m in re.finditer(rf"(? 0 + + +def funciones_de_texto( + src: str, cadenas: Sequence[CadenaJs], patron: str, es_sumidero: Callable[[CadenaJs], bool] +) -> set[tuple[str, int]]: + """Funciones del propio script cuyo parámetro N solo sirve para mostrarse: {(nombre, posición del parámetro)}. + + Pasar una frase en esa posición es escribir un texto visible, aunque la llamada no sea `tell` ni `Text.of`. Es + conservador: si el parámetro se compara, se busca, se guarda, se devuelve o se pasa a algo que no se entiende, la + función queda fuera. La función tampoco puede usarse como valor (`callback = f`): solo se llama. + """ + limpio = _en_blanco(src, cadenas) + plantillas_todas = [ + (c, set(re.findall(_IDENT, " ".join(re.findall(r"\$\{([^{}\n]*)\}", c.texto))))) + for c in cadenas + if c.comilla == "`" and "${" in c.texto + ] + resultado: set[tuple[str, int]] = set() + definiciones: dict[str, int] = {} + encontradas: list[tuple[str, list[str], int, int, int]] = [] + for patron_def in _FUNCIONES: + for m in patron_def.finditer(limpio): + nombre, params = m.group(1), [p.strip() for p in m.group(2).split(",")] + fin = _cierre(limpio, m.end()) + if fin > 0: + definiciones[nombre] = definiciones.get(nombre, 0) + 1 + encontradas.append((nombre, params, m.start(), m.end(), fin)) + for nombre, params, desde, inicio, fin in encontradas: + fuera = limpio[:desde] + " " + limpio[fin:] # el resto del script, sin la propia definición + usos_como_valor = re.search(rf"(? str: base = f"{self.receptor}.{self.funcion}" if self.receptor else str(self.funcion) @@ -178,13 +180,16 @@ def escanear(src: str) -> list[CadenaJs]: if pila and pila[-1].tipo == "(" and pila[-1].primera is None: pila[-1].primera = texto contexto = tuple(m.nombre() for m in pila if m.tipo == "(" and m.funcion) - res.append(CadenaJs(i, fin, c, texto, funcion, receptor, _comparada(src, i, fin), contexto)) + argumento = marco.arg if marco and marco.tipo == "(" else -1 + res.append(CadenaJs(i, fin, c, texto, funcion, receptor, _comparada(src, i, fin), contexto, argumento)) i = fin continue if c == "(": pila.append(_Marco("(", *_llamada(src, i))) elif c in "[{": pila.append(_Marco(c)) + elif c == "," and pila and pila[-1].tipo == "(": + pila[-1].arg += 1 elif c in ")]}" and pila: pila.pop() i += 1 diff --git a/src/traductor_es/formatos/kubejs_api.py b/src/traductor_es/formatos/kubejs_api.py new file mode 100644 index 0000000..3664a8a --- /dev/null +++ b/src/traductor_es/formatos/kubejs_api.py @@ -0,0 +1,32 @@ +"""Métodos de KubeJS que le muestran texto al jugador, según la versión de Minecraft. + +Los verificados salen del código de KubeJS (repositorio KubeJS-Mods/KubeJS, ramas 1202, 1605, 1802, 1902, 2001, +2101 y 2601: `MessageSender(KJS)`, `PlayerJS`/`PlayerKJS`, `ServerPlayerJS`/`ServerPlayerKJS`). El resto son +los de siempre (constructores de objetos, tooltips, títulos) y se aceptan en todas las versiones. +""" + +from __future__ import annotations + +#: método → primera versión de Minecraft (mayor, menor) en la que existe, comprobado en el código de cada rama +VERIFICADOS: dict[str, tuple[int, int]] = { + "tell": (1, 12), # MessageSender.tell(Component) + "setStatusMessage": (1, 12), # MessageSender / PlayerJS.setStatusMessage(Component) + "kick": (1, 12), # ServerPlayerJS.kick(Component) + "notify": (1, 19), # PlayerKJS.notify(título, texto): aparece en la rama 1902 + "openInventoryGUI": (1, 19), # ServerPlayerKJS.openInventoryGUI(…, Component título) + "openChestGUI": (1, 20), # ServerPlayerKJS.openChestGUI(Component título, …): aparece en la rama 2001 +} + +#: Se aceptan en cualquier versión; no se comprobaron una por una contra el código de KubeJS. +GENERALES = frozenset({ + "statusMessage", "displayClientMessage", "sendSystemMessage", "sendMessage", "displayName", "setDisplayName", + "tooltip", "addTooltip", "title", "setTitle", "subtitle", "setSubtitle", "lore", "setLore", "actionbar", + "setActionbar", +}) # fmt: skip + + +def llamadas_de_texto(version: tuple[int, ...] | None) -> frozenset[str]: + """Los métodos de texto que existen en esa versión de Minecraft. Sin versión: todos (es lo más prudente).""" + if version is None: + return GENERALES | VERIFICADOS.keys() + return GENERALES | {m for m, desde in VERIFICADOS.items() if tuple(version[:2]) >= desde} diff --git a/src/traductor_es/formatos/nbt.py b/src/traductor_es/formatos/nbt.py index 60acad8..8123e4d 100644 --- a/src/traductor_es/formatos/nbt.py +++ b/src/traductor_es/formatos/nbt.py @@ -137,7 +137,10 @@ def valor(self, tipo: int) -> Valor: def descomprimir(datos: bytes) -> tuple[bytes, Compresion]: if datos[:2] == b"\x1f\x8b": - return gzip.decompress(datos), "gzip" + try: + return gzip.decompress(datos), "gzip" + except (EOFError, OSError, zlib.error) as e: # gzip truncado o dañado: un solo tipo de error para quien llama + raise ValueError(f"Archivo NBT comprimido dañado: {e}") from e if datos[:1] == b"\x78": try: return zlib.decompress(datos), "zlib" diff --git a/src/traductor_es/fuentes/contenedores.py b/src/traductor_es/fuentes/contenedores.py index 5cd3232..b1358af 100644 --- a/src/traductor_es/fuentes/contenedores.py +++ b/src/traductor_es/fuentes/contenedores.py @@ -73,7 +73,10 @@ def nombres(self) -> list[str]: def leer(self, ruta: str) -> bytes: if not ruta.startswith(self._prefijo): raise KeyError(ruta) - return (self.raiz / ruta[len(self._prefijo) :]).read_bytes() + destino = (self.raiz / ruta[len(self._prefijo) :]).resolve() + if not destino.is_relative_to(self.raiz.resolve()): # «../» o una ruta absoluta: nunca se sale de la carpeta + raise KeyError(ruta) + return destino.read_bytes() def cerrar(self) -> None: return None diff --git a/src/traductor_es/modulos/cobertura.py b/src/traductor_es/modulos/cobertura.py new file mode 100644 index 0000000..feada26 --- /dev/null +++ b/src/traductor_es/modulos/cobertura.py @@ -0,0 +1,271 @@ +"""Qué texto en inglés queda en la instancia aunque se traduzca todo, y por qué. + +La traducción no puede cubrirlo todo: un resource pack no llega al texto escrito en el código de un mod, y hay +cadenas de scripts cuyo uso no se entiende. Este informe lo dice con datos en lugar de dejar al jugador con la duda. +Solo lee; no escribe nada en la instancia. +""" + +from __future__ import annotations + +import json +import re +import struct +import zipfile +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +from ..fuentes.instancia import Instancia +from ..nucleo.mascara import es_traducible +from .base import Contexto, parece_clave +from .datos import clasificar, recorrer +from .scripts import ScriptsKubeJS + +MAX_EJEMPLOS = 3 +#: Nombres que delatan una clase que construye texto: `Component.literal` (Mojang), `m_237113_` (SRG 1.19+), +#: `method_43470` (Fabric) y los `TextComponent`, `StringTextComponent` y `TextComponentString` anteriores. +_MARCAS_TEXTO = ("literal", "m_237113_", "method_43470") +_CLASES_TEXTO = ("TextComponent", "StringTextComponent", "TextComponentString") +#: Una frase en inglés: puede empezar con códigos de color (§c) o con un dato (%s) y llevar números y puntuación. +_FRASE = re.compile(r"(?:§[0-9a-fk-or])*(?:[A-Z][A-Za-z']+|%[sd])(?: [§A-Za-z0-9,.'!?:%\-]+){2,}[.!?:]?") +_ESPANOL = re.compile(r"\b(de|la|el|los|las|con|sin|para|por|una|un|del)\b") +#: Frases de registros y errores internos: casi nunca llegan al jugador. +_INTERNO = re.compile( + r"^(Failed|Could not|Couldn't|Unable|Unexpected|Invalid|Missing|Exception|Error|Cannot|No )", re.I +) +#: Tamaño de cada tipo de constante de un .class (tras la etiqueta), según la especificación de la JVM +_TAMANO_CONSTANTE = { + 3: 4, + 4: 4, + 5: 8, + 6: 8, + 7: 2, + 8: 2, + 9: 4, + 10: 4, + 11: 4, + 12: 4, + 15: 3, + 16: 2, + 17: 4, + 18: 4, + 19: 2, + 20: 2, +} + + +@dataclass(frozen=True, slots=True) +class Hallazgo: + """Una cosa que queda en inglés: dónde, cuántos textos y unos ejemplos.""" + + categoria: str #: «script», «comando», «formato» o «java» + donde: str + cantidad: int + motivo: str + ejemplos: tuple[str, ...] = () + + def __str__(self) -> str: + ej = f" Por ejemplo: {' | '.join(f'«{e}»' for e in self.ejemplos)}." if self.ejemplos else "" + return f"{self.donde}: {self.cantidad} {'texto' if self.cantidad == 1 else 'textos'}. {self.motivo}.{ej}" + + +_TITULOS = { + "java": ( + "Escrito en el código de los mods " + "(un resource pack no lo alcanza; el mod de apoyo cubre Forge, NeoForge y Fabric)" + ), + "script": "Frases de scripts que parecen texto y no se tocaron (pueden ser lógica o un mensaje armado distinto)", + "comando": "Comandos de otros mods con una frase en inglés (la app no conoce su formato y no los toca)", + "formato": "Carpetas con frases en inglés cuyo formato la app no conoce (no se tocó ningún archivo)", +} + + +def texto_del_informe(hallazgos: list[Hallazgo]) -> str: + """El informe como texto para leer o guardar, agrupado por categoría.""" + if not hallazgos: + return "No encontré texto que se quede en inglés.\n" + lineas = [f"Texto que se queda en inglés aunque se traduzca todo: {len(hallazgos)} lugares.", ""] + for categoria in ("script", "comando", "formato", "java"): + grupo = [h for h in hallazgos if h.categoria == categoria] + if not grupo: + continue + lineas += [f"== {_TITULOS[categoria]} ==", ""] + lineas += [f"- {h}" for h in grupo] + lineas.append("") + return "\n".join(lineas) + + +def informe_en_json(hallazgos: list[Hallazgo]) -> str: + """El informe como JSON (UTF-8), para que lo lean otros programas.""" + return json.dumps( + [ + { + "categoria": h.categoria, + "donde": h.donde, + "textos": h.cantidad, + "motivo": h.motivo, + "ejemplos": list(h.ejemplos), + } + for h in hallazgos + ], + ensure_ascii=False, + indent=2, + ) + + +def hallazgos_de_scripts(juego: Path, sin_elegir: list[tuple[Path, list[str]]]) -> list[Hallazgo]: + """Frases de los scripts que parecen texto y que la traducción no tocó (pueden ser mensajes).""" + return [ + Hallazgo( + "script", ruta.relative_to(juego).as_posix(), len(textos), + "Parece una frase, pero no se usa como texto visible (puede ser lógica o un mensaje armado de otra forma)", + tuple(t[:60] for t in textos[:MAX_EJEMPLOS]), + ) + for ruta, textos in sin_elegir + ] # fmt: skip + + +def constantes_utf8(datos: bytes) -> list[str]: + """Las cadenas de la tabla de constantes de un .class (JVMS §4.4). Vacío si el archivo no se entiende.""" + if len(datos) < 10 or datos[:4] != b"\xca\xfe\xba\xbe": + return [] + (cantidad,) = struct.unpack_from(">H", datos, 8) + pos, i = 10, 1 + textos: list[str] = [] + try: + while i < cantidad: + etiqueta = datos[pos] + pos += 1 + if etiqueta == 1: + (largo,) = struct.unpack_from(">H", datos, pos) + textos.append(datos[pos + 2 : pos + 2 + largo].decode("utf-8", "ignore")) + pos += 2 + largo + else: + pos += _TAMANO_CONSTANTE[etiqueta] + i += 1 if etiqueta in (5, 6) else 0 # long y double ocupan dos casillas + i += 1 + except (IndexError, KeyError, struct.error): + return [] + return textos if pos <= len(datos) else [] # si la tabla se pasa del final, el archivo está truncado + + +def _frases_de_clase(datos: bytes) -> list[str]: + constantes = constantes_utf8(datos) + if not any(c in _MARCAS_TEXTO or any(n in c for n in _CLASES_TEXTO) for c in constantes): + return [] # la clase no construye texto para el jugador + return [ + c for c in constantes + if len(c) > 14 and _FRASE.fullmatch(c) and not _INTERNO.match(c) and not _ESPANOL.search(c.lower()) + ] # fmt: skip + + +def frases_por_mod( + instancia: Instancia, progreso: Callable[[int, int], None] = lambda _a, _b: None +) -> list[tuple[Path, list[str]]]: + """Por cada mod, las frases en inglés escritas en su código y mostradas como texto (sin repetir).""" + res: list[tuple[Path, list[str]]] = [] + jars = instancia.jars() + for i, jar in enumerate(jars): + progreso(i, len(jars)) + frases: list[str] = [] + try: + with zipfile.ZipFile(jar) as z: + for info in z.infolist(): + if ( + info.filename.endswith(".class") + and not info.filename.startswith("META-INF") + and info.file_size < 3_000_000 + ): + frases += _frases_de_clase(z.read(info)) + except (zipfile.BadZipFile, OSError): + continue + unicas = list(dict.fromkeys(f for f in frases if es_traducible(f) and not parece_clave(f))) + if unicas: + res.append((jar, unicas)) + return res + + +def textos_en_java(instancia: Instancia, progreso: Callable[[int, int], None] = lambda _a, _b: None) -> list[Hallazgo]: + """Frases en inglés escritas en el código de los mods. Un resource pack no puede traducirlas.""" + res = [ + Hallazgo( + "java", jar.name, len(unicas), + "Escrito en el código del mod: un resource pack no lo traduce", + tuple(f[:60] for f in unicas[:MAX_EJEMPLOS]), + ) + for jar, unicas in frases_por_mod(instancia, progreso) + ] # fmt: skip + return sorted(res, key=lambda h: -h.cantidad) + + +#: Carpetas de un datapack o resource pack que no llevan texto para el jugador (o que ya se traducen por otro lado). +_SIN_TEXTO = frozenset({ + "recipes", "recipe", "tags", "loot_tables", "loot_table", "predicates", "worldgen", "dimension", "dimension_type", + "structures", "structure", "lang", "textures", "models", "blockstates", "sounds", "particles", "shaders", "font", + "atlases", "item_modifiers", "chat_type", "damage_type", "enchantment", "trim_pattern", "trim_material", + "banner_pattern", "jukebox_song", "painting_variant", "patchouli_books", "optifine", "equipment", "items", +}) # fmt: skip +#: Un valor de texto con aspecto de frase en inglés dentro de un JSON. +_TEXTO_JSON = re.compile(r'"(?:text|title|description|message|tip|name|lore)"\s*:\s*"([A-Z][^"\\]{11,})"') +_TAMANO_MAXIMO = 300_000 + + +def hallazgos_de_comandos(juego: Path, ajenos: list[tuple[Path, list[str]]]) -> list[Hallazgo]: + """Comandos de otros mods (p. ej. `emberstextapi queue`) con una frase en inglés: no se traducen.""" + return [ + Hallazgo( + "comando", ruta.relative_to(juego).as_posix(), len(textos), + "Se le pasa a un comando de otro mod cuyo formato la app no conoce: traducirlo podría romper sus etiquetas", + tuple(t[:100] for t in textos[:MAX_EJEMPLOS]), # más largo: la frase va detrás del nombre del comando + ) + for ruta, textos in ajenos + ] # fmt: skip + + +def formatos_desconocidos(juego: Path) -> list[Hallazgo]: + """Carpetas de datos o recursos con frases en inglés cuyo formato la app no conoce (no se tocan).""" + por_carpeta: dict[str, list[str]] = {} + for ruta in recorrer(juego): + partes = ruta.relative_to(juego).parts + if ruta.suffix != ".json" or clasificar(Path(*partes)) is not None: + continue + ancla = next( + (i for i, parte in enumerate(partes) if parte in ("data", "assets") and len(partes) - i >= 4), None + ) + if ancla is None or partes[ancla + 2] in _SIN_TEXTO: + continue + try: + if ruta.stat().st_size > _TAMANO_MAXIMO: + continue + frases = [ + f + for f in _TEXTO_JSON.findall(ruta.read_text(encoding="utf-8-sig", errors="ignore")) + if es_traducible(f) + ] + except OSError: + continue + if frases: + por_carpeta.setdefault("/".join(partes[: ancla + 3]), []).extend(frases) + return [ + Hallazgo( + "formato", carpeta, len(frases), "Formato que la app no conoce: no se traduce (no se toca ningún archivo)", + tuple(f[:60] for f in frases[:MAX_EJEMPLOS]), + ) + for carpeta, frases in sorted(por_carpeta.items(), key=lambda kv: -len(kv[1])) + ] # fmt: skip + + +def revisar_cobertura( + ctx: Contexto, java: bool = False, progreso: Callable[[int, int], None] = lambda _a, _b: None +) -> list[Hallazgo]: + """Lo que queda en inglés. El análisis del código Java tarda (lee todos los mods): solo si se pide.""" + scripts = ScriptsKubeJS(ctx) + hallazgos = hallazgos_de_scripts(ctx.instancia.juego, scripts.candidatas()) + hallazgos += hallazgos_de_comandos(ctx.instancia.juego, scripts.comandos_ajenos) + hallazgos += formatos_desconocidos(ctx.instancia.juego) + if java: + hallazgos += textos_en_java(ctx.instancia, progreso) + return hallazgos + + +__all__ = ["Hallazgo", "frases_por_mod", "revisar_cobertura"] diff --git a/src/traductor_es/modulos/datos.py b/src/traductor_es/modulos/datos.py new file mode 100644 index 0000000..34d2aa9 --- /dev/null +++ b/src/traductor_es/modulos/datos.py @@ -0,0 +1,307 @@ +"""Textos de datapacks y packs de recursos que el jugador ve en el chat o en pantalla: funciones, logros y tips. + +Los archivos se buscan por su **estructura** y no por la carpeta de un modpack concreto: cualquier ruta que +contenga `data//function(s)/…`, `data//advancement(s)/…` o `assets//tips/…` cuenta, +esté en `kubejs/data`, `global_packs/*/*`, `datapacks`, `config/openloader`, `saves/*/datapacks`… Cada formato +reconocido es un `Formato` con su documentación; lo que no se reconoce no se toca. Se traduce en el sitio, con +copia de seguridad. +""" + +from __future__ import annotations + +import io +import json +import os +import zipfile +from collections.abc import Callable, Iterator, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, cast + +from ..formatos import comandos, componente +from ..nucleo.mascara import es_traducible +from ..salida.resource_pack import NOMBRE as NOMBRE_PACK_SALIDA +from .base import Contexto, Pieza, Resumen, contar, parece_clave +from .scripts import AMBITO, aplicar_comando, unidades_comando + +#: Carpetas que nunca se recorren: cachés, registros, mods (sus jars se leen en otro sitio) y lo que genera la app. +IGNORAR = frozenset({ + "mods", "logs", "crash-reports", "traductor_es", "cache", "libraries", "versions", "screenshots", "shaderpacks", + "backups", "probe", "node_modules", "modernfix", "xaerowaypoints", "xaeroworldmap", "journeymap", + NOMBRE_PACK_SALIDA.lower(), +}) # fmt: skip +PROFUNDIDAD_MAXIMA = 10 +LIMITE_ZIP = 64 * 1024 * 1024 #: más grande que esto no es un datapack, es un resource pack pesado: no se abre +CAMPOS_LOGRO = ("title", "description") + + +def _componente(valor: Any) -> str: + return json.dumps(valor, ensure_ascii=False) + + +def _traducibles(partes: list[str] | None) -> list[str]: + return [p for p in partes or [] if not parece_clave(p)] + + +def _nodos(datos: Any, claves: tuple[str, ...]) -> list[tuple[dict[str, Any], str]]: + """(diccionario, clave) de cada valor de texto que hay que traducir dentro de un JSON.""" + if not isinstance(datos, dict): + return [] + d = cast("dict[str, Any]", datos) + return [(d, k) for k in claves if k in d] + + +# ---------------------------------------------------------------- funciones (.mcfunction) +def _lineas(texto: str) -> list[tuple[str, comandos.Comando | None, str]]: + """Cada línea separada en (línea sin fin, su comando de chat, fin de línea).""" + res: list[tuple[str, comandos.Comando | None, str]] = [] + for linea in texto.split("\n"): + fin = "\r" if linea.endswith("\r") else "" + cuerpo = linea[: len(linea) - len(fin)] + cmd = comandos.analizar(cuerpo) + res.append((cuerpo, cmd if cmd is not None and unidades_comando(cmd) else None, fin)) + return res + + +def _unidades_funcion(texto: str) -> list[str]: + return [t for _, cmd, _ in _lineas(texto) if cmd for t in unidades_comando(cmd)] + + +def _reescribir_funcion(texto: str, traducciones: Mapping[str, str]) -> str: + return "\n".join((aplicar_comando(cmd, traducciones) if cmd else linea) + fin for linea, cmd, fin in _lineas(texto)) + + +# ---------------------------------------------------------------- JSON con componentes (logros, tips) +def _nodos_logro(datos: Any) -> list[tuple[dict[str, Any], str]]: + """`display.title` y `display.description` de un logro.""" + display = cast("dict[str, Any]", datos).get("display") if isinstance(datos, dict) else None + return _nodos(display, CAMPOS_LOGRO) + + +def _nodos_tip(datos: Any) -> list[tuple[dict[str, Any], str]]: + """Tips: `{"tip": …}` (Tips de Darkhax) y `{"tips": [{"text": …}]}` (el port de NeoForge).""" + if not isinstance(datos, dict): + return [] + d = cast("dict[str, Any]", datos) + nodos = _nodos(d, ("tip",)) + lista = d.get("tips") + if isinstance(lista, list): + for t in cast("list[Any]", lista): + nodos += _nodos(t, ("text",)) + return nodos + + +def _nodos_mercado(datos: Any) -> list[tuple[dict[str, Any], str]]: + """Mercado de Farming for Blockheads: el `name` de cada `customCategories.` (lo que se ve en las pestañas).""" + if not isinstance(datos, dict): + return [] + categorias = cast("dict[str, Any]", datos).get("customCategories") + if not isinstance(categorias, dict): + return [] + return [n for c in cast("dict[str, Any]", categorias).values() for n in _nodos(c, ("name",))] + + +def _unidades_json(texto: str, nodos: Callable[[Any], list[tuple[dict[str, Any], str]]]) -> list[str]: + return [u for d, k in nodos(json.loads(texto)) for u in _traducibles(componente.unidades(_componente(d[k])))] + + +def _reescribir_json( + texto: str, traducciones: Mapping[str, str], nodos: Callable[[Any], list[tuple[dict[str, Any], str]]] +) -> str: + datos = json.loads(texto) + for d, k in nodos(datos): + d[k] = json.loads(componente.reconstruir(_componente(d[k]), traducciones)) + return json.dumps(datos, indent=2, ensure_ascii=False) + "\n" + + +@dataclass(frozen=True, slots=True) +class Formato: + nombre: str + pista: str #: contexto que se le da al motor de traducción + reconoce: Callable[[tuple[str, ...]], bool] #: recibe la ruta desde «data» o «assets»: (data, espacio, carpeta, …) + unidades: Callable[[str], list[str]] #: puede lanzar ValueError si el archivo no se entiende + reescribir: Callable[[str, Mapping[str, str]], str] + + +def _es(ruta: tuple[str, ...], raiz: str, carpetas: tuple[str, ...], sufijo: str) -> bool: + return len(ruta) >= 4 and ruta[0] == raiz and ruta[2] in carpetas and ruta[-1].lower().endswith(sufijo) + + +FORMATOS: tuple[Formato, ...] = ( + Formato( + "función", "mensaje de una función", + lambda r: _es(r, "data", ("function", "functions"), ".mcfunction"), + _unidades_funcion, _reescribir_funcion, + ), + Formato( + "logro", "logro del juego", + lambda r: _es(r, "data", ("advancement", "advancements"), ".json"), + lambda t: _unidades_json(t, _nodos_logro), lambda t, tr: _reescribir_json(t, tr, _nodos_logro), + ), + Formato( + "mercado", "pestaña de un mercado", + lambda r: _es(r, "data", ("farmingforblockheads_compat",), ".json"), + lambda t: _unidades_json(t, _nodos_mercado), lambda t, tr: _reescribir_json(t, tr, _nodos_mercado), + ), + Formato( + "tip", "consejo de las pantallas de carga", + lambda r: _es(r, "assets", ("tips",), ".json") or (_es(r, "data", ("tips",), ".json") and r[1] == "tipsmod"), + lambda t: _unidades_json(t, _nodos_tip), lambda t, tr: _reescribir_json(t, tr, _nodos_tip), + ), +) # fmt: skip + + +def clasificar(ruta: Path) -> Formato | None: + """El formato de este archivo según su ruta, o None. Busca el ancla «data» o «assets» en cualquier posición.""" + partes = ruta.parts + for i, parte in enumerate(partes): + if parte in ("data", "assets"): + for formato in FORMATOS: + if formato.reconoce(partes[i:]): + return formato + return None + + +def recorrer(juego: Path) -> Iterator[Path]: + """Cada archivo de la instancia, sin entrar en mods, cachés ni mundos (de un mundo solo sus datapacks).""" + base = len(juego.parts) + for raiz, carpetas, archivos in os.walk(juego): + actual = Path(raiz) + if len(actual.parts) - base >= PROFUNDIDAD_MAXIMA: + carpetas.clear() + elif actual.parent.name.lower() == "saves": + carpetas[:] = [c for c in carpetas if c.lower() == "datapacks"] + else: + carpetas[:] = sorted(c for c in carpetas if c.lower() not in IGNORAR and not c.startswith(".")) + for nombre in sorted(archivos): + yield actual / nombre + + +def buscar_archivos(juego: Path) -> list[tuple[Path, Formato]]: + """Todos los archivos de texto conocidos que están sueltos en la instancia.""" + encontrados: list[tuple[Path, Formato]] = [] + for ruta in recorrer(juego): + if (formato := clasificar(ruta.relative_to(juego))) is not None: + encontrados.append((ruta, formato)) + return encontrados + + +def entradas_del_zip(ruta: Path) -> list[tuple[str, Formato]]: + """Las entradas de un .zip (datapack o pack de recursos) con texto conocido. Solo lee su índice.""" + if ruta.stat().st_size > LIMITE_ZIP: + return [] + try: + with zipfile.ZipFile(ruta) as z: + return [(n, f) for n in z.namelist() if not n.endswith("/") and (f := clasificar(Path(n))) is not None] + except (zipfile.BadZipFile, OSError): + return [] + + +def buscar_zips(juego: Path) -> list[tuple[Path, list[tuple[str, Formato]]]]: + """Los .zip con texto conocido dentro (datapacks de `global_packs`, `datapacks`, mundos…).""" + res: list[tuple[Path, list[tuple[str, Formato]]]] = [] + for ruta in recorrer(juego): + if ruta.suffix.lower() == ".zip" and (entradas := entradas_del_zip(ruta)): + res.append((ruta, entradas)) + return res + + +@dataclass(slots=True) +class _Archivo: + ruta: Path #: el archivo suelto o el .zip que lo contiene + formato: Formato + texto: str + piezas: list[str] = field(default_factory=list[str]) + entrada: str | None = None #: nombre dentro del .zip; None si es un archivo suelto + + +def _reescribir_zip(original: bytes, nuevos: Mapping[str, str]) -> bytes: + """El mismo .zip con esas entradas cambiadas; las demás, el orden y las fechas quedan igual.""" + salida = io.BytesIO() + with zipfile.ZipFile(io.BytesIO(original)) as entrada, zipfile.ZipFile(salida, "w") as z: + for info in entrada.infolist(): + datos = nuevos[info.filename].encode("utf-8") if info.filename in nuevos else entrada.read(info) + z.writestr(info, datos, compress_type=info.compress_type) + return salida.getvalue() + + +class Datos: + nombre = "Funciones, logros y tips" + ambito = AMBITO + + def __init__(self, ctx: Contexto) -> None: + self.ctx = ctx + self._leidos: list[_Archivo] = [] + self._zips: dict[Path, bytes] = {} #: contenido original de cada .zip leído + + def detectar(self) -> bool: + juego = self.ctx.instancia.juego + return bool(buscar_archivos(juego) or buscar_zips(juego)) + + def describir(self) -> str: + juego = self.ctx.instancia.juego + n = len(buscar_archivos(juego)) + sum(len(e) for _, e in buscar_zips(juego)) + return contar(n, "archivo de datos", "archivos de datos") + + def _leer(self, ruta: Path, formato: Formato, datos: bytes, entrada: str | None) -> list[Pieza]: + try: + texto = datos.decode("utf-8-sig") + unidades = [t for t in formato.unidades(texto) if es_traducible(t)] + except (ValueError, UnicodeDecodeError): + self.ctx.registro(f"{entrada or ruta.name}: no pude leerlo con seguridad, se deja igual") + return [] + if not unidades: + return [] + self._leidos.append(_Archivo(ruta, formato, texto, unidades, entrada)) + return [Pieza(t, formato.pista) for t in unidades] + + def recolectar(self) -> list[Pieza]: + self._leidos, self._zips = [], {} + juego = self.ctx.instancia.juego + piezas: list[Pieza] = [] + for ruta, formato in buscar_archivos(juego): + if (original := self.ctx.respaldo.original(ruta)) is not None: + piezas += self._leer(ruta, formato, original, None) + for ruta, entradas in buscar_zips(juego): + if (original := self.ctx.respaldo.original(ruta)) is None: + continue + try: + with zipfile.ZipFile(io.BytesIO(original)) as z: + contenidos = [(n, f, z.read(n)) for n, f in entradas] + except (zipfile.BadZipFile, OSError, KeyError): + self.ctx.registro(f"{ruta.name}: no pude abrirlo, se deja igual") + continue + self._zips[ruta] = original + for nombre, formato, datos in contenidos: + piezas += self._leer(ruta, formato, datos, nombre) + return piezas + + def escribir(self, traducciones: Mapping[str, str]) -> Resumen: + resumen = Resumen() + en_zip: dict[Path, dict[str, str]] = {} + for a in self._leidos: + hechas = sum(1 for t in set(a.piezas) if t in traducciones) + resumen.traducidos += hechas + resumen.pendientes += len(set(a.piezas)) - hechas + nuevo = a.formato.reescribir(a.texto, traducciones) + if not hechas or nuevo == a.texto: + continue + if a.entrada is not None: + en_zip.setdefault(a.ruta, {})[a.entrada] = nuevo + elif not a.ruta.is_file() or a.ruta.read_bytes() != nuevo.encode("utf-8"): # en bytes: sin traducir \r\n + self.ctx.respaldo.escribir(a.ruta, nuevo.encode("utf-8")) + resumen.archivos.append(a.ruta) + for ruta, nuevos in en_zip.items(): + datos = _reescribir_zip(self._zips[ruta], nuevos) + if ruta.read_bytes() != datos: + self.ctx.respaldo.escribir(ruta, datos) + resumen.archivos.append(ruta) + return resumen + + def restaurar(self) -> list[Path]: + juego = self.ctx.instancia.juego + propios = [r for r in self.ctx.respaldo.registrados() if clasificar(Path(r)) is not None or r.endswith(".zip")] + hechos: list[Path] = [] + for ruta in propios: # Respaldo restaura por prefijo: la ruta completa es el prefijo más estricto + hechos += self.ctx.respaldo.restaurar(ruta) if (juego / ruta).exists() else [] + return hechos diff --git a/src/traductor_es/modulos/libros.py b/src/traductor_es/modulos/libros.py index fa3dc6a..fa4a2c6 100644 --- a/src/traductor_es/modulos/libros.py +++ b/src/traductor_es/modulos/libros.py @@ -255,30 +255,36 @@ def generar(trad: Mapping[str, str]) -> tuple[bytes, int, int]: def escribir(self, traducciones: Mapping[str, str]) -> Resumen: resumen = Resumen() for a in self._archivos: - generado: bytes | None = None - for variante in self.ctx.variantes: - destino = a.destino(variante) - if variante in a.existentes: - # Ya hay traducción del autor: si el pack tiene una copia vieja en esa ruta, que sea la oficial - if not a.externo and self._en_pack(destino) not in (None, a.existentes[variante]): - resumen.archivos.append(self.ctx.pack_listo().escribir(destino, a.existentes[variante])) - continue - copia = next((a.existentes[v] for v in (*_PREFERIDAS, *a.existentes) if v in a.existentes), None) - if copia is not None: - contenido = copia - else: - if a.generar is None: - continue - if generado is None: - generado, hechos, faltan = a.generar(traducciones) - resumen.traducidos += hechos - resumen.pendientes += faltan - if hechos == 0: - break # nada traducido: el juego ya usa el inglés por su cuenta - contenido = generado - resumen.archivos.append(self._escribir(a, destino, contenido)) + try: + self._escribir_libro(a, traducciones, resumen) + except ValueError as e: # una ruta imposible de guardar en el pack: ese libro se deja en inglés + self.ctx.registro(f"No pude guardar un libro: {e}") return resumen + def _escribir_libro(self, a: _Archivo, traducciones: Mapping[str, str], resumen: Resumen) -> None: + generado: bytes | None = None + for variante in self.ctx.variantes: + destino = a.destino(variante) + if variante in a.existentes: + # Ya hay traducción del autor: si el pack tiene una copia vieja en esa ruta, que sea la oficial + if not a.externo and self._en_pack(destino) not in (None, a.existentes[variante]): + resumen.archivos.append(self.ctx.pack_listo().escribir(destino, a.existentes[variante])) + continue + copia = next((a.existentes[v] for v in (*_PREFERIDAS, *a.existentes) if v in a.existentes), None) + if copia is not None: + contenido = copia + else: + if a.generar is None: + continue + if generado is None: + generado, hechos, faltan = a.generar(traducciones) + resumen.traducidos += hechos + resumen.pendientes += faltan + if hechos == 0: + break # nada traducido: el juego ya usa el inglés por su cuenta + contenido = generado + resumen.archivos.append(self._escribir(a, destino, contenido)) + def _en_pack(self, relativa: str) -> bytes | None: pack = self.ctx.pack ruta = (pack.raiz if pack else self.ctx.instancia.juego / "resourcepacks" / "Traduccion_ES") / relativa diff --git a/src/traductor_es/modulos/mods.py b/src/traductor_es/modulos/mods.py index 23da12d..f9a10bb 100644 --- a/src/traductor_es/modulos/mods.py +++ b/src/traductor_es/modulos/mods.py @@ -82,7 +82,13 @@ def escribir(self, traducciones: Mapping[str, str]) -> Resumen: return resumen # nada que escribir y nada viejo que vaciar: no se crea un pack vacío pack = self.ctx.pack_listo() for (namespace, variante), textos in sorted(por_archivo.items()): - resumen.archivos.append(pack.escribir_lang(namespace, variante, textos)) + try: + resumen.archivos.append(pack.escribir_lang(namespace, variante, textos)) + except ValueError: # un mod con un espacio de nombres imposible de guardar no detiene a los demás + self.ctx.registro( + f"El espacio de nombres «{namespace}» no se puede guardar en el pack: se deja en inglés" + ) + resumen.pendientes += len(textos) pack.vaciar_idiomas_viejos() return resumen diff --git a/src/traductor_es/modulos/runtime.py b/src/traductor_es/modulos/runtime.py new file mode 100644 index 0000000..d1cf05e --- /dev/null +++ b/src/traductor_es/modulos/runtime.py @@ -0,0 +1,82 @@ +"""Texto escrito en el código Java de los mods: se traduce a una tabla que lee el mod de apoyo (ModpackES Runtime). + +Un resource pack no puede cambiar un texto que el mod tiene escrito en su código. El mod de apoyo, en el +cliente, cambia un mensaje de chat, de la barra de acción o un tooltip solo si su texto es **exactamente** uno de +la tabla `assets/modpackes_runtime/textos/.json` que se genera aquí (inglés → español). +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from pathlib import Path + +from ..formatos import lang_json +from ..nucleo.mascara import es_traducible +from .base import Contexto, Pieza, Resumen, contar +from .cobertura import frases_por_mod + +AMBITO = "ui" +RUTA_TABLA = "assets/modpackes_runtime/textos/{variante}.json" +#: Datos variables de un `String.format`: el mod los trata como una plantilla («Hello %s!» coincide con «Hello Steve!»). +_MARCADORES = re.compile(r"%(?:\d+\$)?[sd%]") + + +def _marcadores(texto: str) -> list[str]: + return _MARCADORES.findall(texto) + + +class TextoDeJava: + nombre = "Texto del código de los mods" + ambito = AMBITO + + def __init__(self, ctx: Contexto) -> None: + self.ctx = ctx + self._frases: list[tuple[Path, list[str]]] = [] + + def detectar(self) -> bool: + return bool(self.ctx.instancia.jars()) + + def describir(self) -> str: + total = sum(len(f) for _, f in self._frases) + return ( + f"{contar(total, 'frase')} en {contar(len(self._frases), 'mod')}" + if self._frases + else "frases del código de los mods" + ) + + def recolectar(self) -> list[Pieza]: + self._frases = frases_por_mod(self.ctx.instancia) + return [Pieza(f, self._pista(jar.name, f)) for jar, frases in self._frases for f in frases] + + @staticmethod + def _pista(mod: str, frase: str) -> str: + base = f"texto del código de {mod}" + return f"{base}; los marcadores %s y %d son datos: déjalos en el mismo orden" if _marcadores(frase) else base + + def escribir(self, traducciones: Mapping[str, str]) -> Resumen: + resumen = Resumen() + tabla: dict[str, str] = {} + for _, frases in self._frases: + for frase in frases: + traducida = traducciones.get(frase) + if traducida is None or traducida == frase or not es_traducible(frase): + resumen.pendientes += 1 + elif _marcadores(traducida) != _marcadores(frase): + # El mod rellena los datos en orden: si la traducción los cambia de sitio, mostraría un dato + # en el lugar de otro. Mejor dejar el inglés. + self.ctx.registro(f"No uso «{traducida[:50]}»: cambió el orden de los datos de «{frase[:50]}»") + resumen.pendientes += 1 + else: + tabla[frase] = traducida + resumen.traducidos = len(tabla) + if not tabla: + return resumen + pack = self.ctx.pack_listo() + datos = lang_json.escribir(dict(sorted(tabla.items()))) + for variante in self.ctx.variantes: # el mod lee la variante que use el jugador + resumen.archivos.append(pack.escribir(RUTA_TABLA.format(variante=variante), datos)) + return resumen + + def restaurar(self) -> list[Path]: + return [] # la tabla va en el resource pack: basta con desactivarlo diff --git a/src/traductor_es/modulos/scripts.py b/src/traductor_es/modulos/scripts.py index ae4dd1c..cda6a29 100644 --- a/src/traductor_es/modulos/scripts.py +++ b/src/traductor_es/modulos/scripts.py @@ -8,22 +8,22 @@ import re from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path -from ..formatos import javascript +from ..formatos import comandos, componente, flujo_js, javascript +from ..formatos.kubejs_api import llamadas_de_texto from ..nucleo.mascara import es_traducible from .base import Contexto, Pieza, Resumen, contar, parece_clave AMBITO = "script" CARPETAS = ("kubejs/client_scripts", "kubejs/server_scripts", "kubejs/startup_scripts") -#: Llamadas cuyo argumento de texto es siempre algo que ve el jugador. -LLAMADAS_TEXTO = frozenset({ - "tell", "statusMessage", "setStatusMessage", "displayClientMessage", "sendSystemMessage", "sendMessage", - "displayName", "setDisplayName", "tooltip", "addTooltip", "title", "setTitle", "subtitle", "setSubtitle", - "lore", "setLore", "actionbar", "setActionbar", -}) # fmt: skip +#: Llamadas cuyo argumento de texto es siempre algo que ve el jugador (de todas las versiones de KubeJS; la lista por +#: versión está en `formatos/kubejs_api.py`). +LLAMADAS_TEXTO = llamadas_de_texto(None) +#: Llamadas que ejecutan un comando: si es `tellraw`, `title`, `say`…, el texto del mensaje se traduce. +LLAMADAS_COMANDO = frozenset({"runCommand", "runCommandSilent"}) #: `Text.of("…")`, `Text.red("…")`, `Component.literal("…")`… AYUDAS_TEXTO = frozenset({ "of", "literal", "string", "black", "darkBlue", "darkGreen", "darkAqua", "darkRed", "darkPurple", "gold", "gray", @@ -37,7 +37,25 @@ "onEvent:client.generate_assets", ) # fmt: skip LLAMADAS_EN_EVENTO = frozenset({"add", "addAll", "insert", "addAdvanced", "addItem", "addFluid", "addLang", "addLine"}) + + +def _llamadas_re(llamadas: frozenset[str]) -> str: + """Las llamadas de texto (`tell`, `Text.of`…) como regex, para saber qué variables acaban en un mensaje.""" + return ( + rf"\b(?:{'|'.join(sorted(llamadas))})|\b(?:{'|'.join(sorted(RECEPTORES_TEXTO))})" + rf"\.(?:{'|'.join(sorted(AYUDAS_TEXTO))})" + ) + + _EXPRESION = re.compile(r"\$\{[^{}\n]*\}") +#: Lo que va entre `<>` o `[]` en el texto de otro mod (etiquetas de formato) o entre `${}` (código del script). +_ETIQUETA = re.compile(r"<[^<>\n]*>|\[[^\[\]\n]*\]|\$\{[^{}\n]*\}") +_PALABRA = re.compile(r"[A-Za-z']+") +#: Palabras de enlace del inglés que casi nunca aparecen en un comando: dos o más indican una frase. +_ENLACES_INGLES = frozenset({ + "the", "and", "of", "to", "is", "are", "you", "your", "it", "we", "that", "this", "be", "have", "will", + "can", "not", "with", "for", "from", "until", "when", "if", "my", "i", +}) # fmt: skip def _parece_texto(texto: str) -> bool: @@ -51,33 +69,154 @@ def _seguro(c: javascript.CadenaJs, traduccion: str) -> bool: return c.comilla != "`" or traduccion.count("${") == c.texto.count("${") -def es_texto_visible(c: javascript.CadenaJs) -> bool: +def es_texto_visible(c: javascript.CadenaJs, llamadas: frozenset[str] = LLAMADAS_TEXTO) -> bool: if c.comparada or not _parece_texto(c.texto): return False if c.comilla == "`" and c.texto.count("${") != len(_EXPRESION.findall(c.texto)): return False # plantilla con expresiones complejas: no se puede proteger con seguridad - if c.funcion in LLAMADAS_TEXTO: + if c.funcion in llamadas: return True if c.funcion in AYUDAS_TEXTO and c.receptor in RECEPTORES_TEXTO: return True return c.funcion in LLAMADAS_EN_EVENTO and any(ctx.startswith(EVENTOS_TEXTO) for ctx in c.contexto) +_LOGICA = ("recipes", "tags", "loot", "worldgen", "registry", "highPriorityData") + + +def parece_frase_sin_elegir(c: javascript.CadenaJs) -> bool: + """Una cadena con aspecto de frase (3 palabras o más, empieza en mayúscula) fuera de recetas y etiquetas.""" + t = c.texto.strip() + return ( + not c.comparada + and c.receptor != "console" # los registros del script no los ve el jugador + and t[:1].isupper() + and len(t.split()) >= 3 + and _parece_texto(t) + and not any(m in ctx for ctx in c.contexto for m in _LOGICA) + ) + + +def _plantilla_segura(c: javascript.CadenaJs) -> bool: + return c.comilla != "`" or c.texto.count("${") == len(_EXPRESION.findall(c.texto)) + + +def _es_sumidero(c: javascript.CadenaJs, llamadas: frozenset[str]) -> bool: + """La cadena se le pasa directamente a algo que le muestra texto al jugador (`tell`, `Text.of`…).""" + return c.funcion in llamadas or (c.funcion in AYUDAS_TEXTO and c.receptor in RECEPTORES_TEXTO) + + +def es_texto_de_funcion(c: javascript.CadenaJs, funciones: set[tuple[str, int]]) -> bool: + """La cadena va en la posición de un parámetro que la función solo muestra (ver `flujo_js.funciones_de_texto`).""" + if c.receptor is not None or c.funcion is None or c.comparada or (c.funcion, c.arg) not in funciones: + return False # una llamada con receptor (`x.f(...)`) es un método, no la función del script + t = c.texto.strip() + return _plantilla_segura(c) and es_traducible(t) and not parece_clave(t) and any(ch.isalpha() for ch in t) + + +def textos_armados( + src: str, cadenas: list[javascript.CadenaJs], flujo: flujo_js.Flujo, tablas: frozenset[str] +) -> list[javascript.CadenaJs]: + """Cadenas que son parte de un mensaje armado por trozos: `let msg = 'Hola '; msg += x; player.tell(msg)`. + + Se eligen las que se asignan o suman a una variable que acaba en `tell`…, los valores `name:` de lo que se + suma a ella (`msg += efecto.name`) y los valores de las tablas de nombres que se suman (`msg += nombres[k]`). + """ + cuerpos = flujo_js.cuerpos_de_tabla(src, cadenas, tablas) + elegidas: list[javascript.CadenaJs] = [] + for c in cadenas: + if c.comparada or not _plantilla_segura(c) or not any(ch.isalpha() for ch in c.texto): + continue + if flujo_js.asignada_a(src, c) in flujo.variables: + # Trozos como «or » o «and » no parecen texto solos, pero aquí sí lo son + visible = es_traducible(c.texto.strip()) and not parece_clave(c.texto) + else: + clave = flujo_js.clave_de(src, c) + visible = ( + clave is not None + and (clave in flujo.miembros or any(a <= c.inicio < b for a, b in cuerpos)) + and _parece_texto(c.texto) + ) + if visible: + elegidas.append(c) + return elegidas + + +def unidades_comando(cmd: comandos.Comando) -> list[str]: + """Los textos que ve el jugador dentro de un comando de chat.""" + if not cmd.json: + return [cmd.cuerpo] if _parece_texto(cmd.cuerpo) else [] + partes = componente.unidades(cmd.cuerpo) # None: formato que no se reconoce, no se toca + return [p for p in partes or [] if not parece_clave(p)] + + +def aplicar_comando(cmd: comandos.Comando, traducciones: Mapping[str, str]) -> str: + if not cmd.json: + return cmd.prefijo + traducciones.get(cmd.cuerpo, cmd.cuerpo) + return cmd.prefijo + componente.reconstruir(cmd.cuerpo, traducciones) + + +def comando_visible(c: javascript.CadenaJs) -> comandos.Comando | None: + """El comando de chat que esta cadena le pasa a `runCommand`, si lo hay y trae texto para traducir.""" + if c.comparada or c.funcion not in LLAMADAS_COMANDO: + return None + if c.comilla == "`" and c.texto.count("${") != len(_EXPRESION.findall(c.texto)): + return None + cmd = comandos.analizar(c.texto) + return cmd if cmd is not None and unidades_comando(cmd) else None + + +def frase_en_comando_ajeno(c: javascript.CadenaJs) -> bool: + """`runCommand` recibe un comando que no es de chat y que lleva una frase en inglés (p. ej. `emberstextapi queue`). + + La app no conoce el formato de los comandos de otros mods y no los toca: solo se avisa en el informe. + """ + if c.comparada or c.funcion not in LLAMADAS_COMANDO or comandos.analizar(c.texto) is not None: + return False + palabras = _PALABRA.findall(_ETIQUETA.sub(" ", c.texto)) + return len(palabras) >= 5 and sum(p.lower() in _ENLACES_INGLES for p in palabras) >= 2 + + @dataclass(slots=True) class _Script: ruta: Path src: str cadenas: list[javascript.CadenaJs] elegidas: list[javascript.CadenaJs] + #: Cadenas elegidas que son un comando de chat (por posición): se traduce el mensaje, no la línea entera. + ordenes: dict[int, comandos.Comando] = field(default_factory=dict[int, comandos.Comando]) + + def unidades(self, c: javascript.CadenaJs) -> list[str]: + cmd = self.ordenes.get(c.inicio) + return [c.texto] if cmd is None else unidades_comando(cmd) + + def nuevo(self, c: javascript.CadenaJs, traducciones: Mapping[str, str]) -> str | None: + """El texto nuevo de la cadena, o None si no hay traducción o no es seguro usarla.""" + cmd = self.ordenes.get(c.inicio) + if cmd is None: + nuevo = traducciones.get(c.texto) + else: + nuevo = aplicar_comando(cmd, traducciones) + if nuevo == c.texto: + return None # ninguna parte del mensaje tiene traducción + return nuevo if nuevo is not None and _seguro(c, nuevo) else None class ScriptsKubeJS: nombre = "Scripts de KubeJS" ambito = AMBITO - def __init__(self, ctx: Contexto) -> None: + def __init__(self, ctx: Contexto, exhaustivo: bool = False) -> None: + """`exhaustivo`: traduce también toda cadena con aspecto de frase, aunque no se sepa dónde se muestra. + + Puede tocar lógica del script (una frase que se compara o se guarda), por eso es opcional. + """ self.ctx = ctx + self.exhaustivo = exhaustivo self._scripts: list[_Script] = [] + self.sin_elegir: list[tuple[Path, list[str]]] = [] + #: Por script, los comandos de otros mods con una frase en inglés (no se tocan; van al informe) + self.comandos_ajenos: list[tuple[Path, list[str]]] = [] def _archivos(self) -> list[Path]: juego = self.ctx.instancia.juego @@ -89,31 +228,74 @@ def detectar(self) -> bool: def describir(self) -> str: return contar(len(self._archivos()), "script") - def recolectar(self) -> list[Pieza]: - self._scripts = [] + def _leer(self) -> list[tuple[Path, str, list[javascript.CadenaJs]]]: + leidos: list[tuple[Path, str, list[javascript.CadenaJs]]] = [] for ruta in self._archivos(): original = self.ctx.respaldo.original(ruta) if original is None: continue try: src = original.decode("utf-8") - cadenas = javascript.escanear(src) + leidos.append((ruta, src, javascript.escanear(src))) except (ValueError, UnicodeDecodeError): self.ctx.registro(f"{ruta.name}: no pude leerlo con seguridad, se deja igual") - continue - elegidas = [c for c in cadenas if es_texto_visible(c)] + return leidos + + def candidatas(self) -> list[tuple[Path, list[str]]]: + """Por script, las frases que no se eligieron para traducir (para el informe de cobertura).""" + self.recolectar() + return self.sin_elegir + + def recolectar(self) -> list[Pieza]: + self._scripts = [] + self.sin_elegir = [] + self.comandos_ajenos = [] + leidos = self._leer() + llamadas = llamadas_de_texto(self.ctx.instancia.version.partes) # las de esta versión de KubeJS + patron = _llamadas_re(llamadas) + flujos = [flujo_js.analizar(src, patron) for _, src, _ in leidos] + # Una tabla de nombres se define en un archivo y se usa en otro + tablas: frozenset[str] = frozenset(t for f in flujos for t in f.tablas) + for (ruta, src, cadenas), flujo in zip(leidos, flujos, strict=True): + elegidas: list[javascript.CadenaJs] = [] + en_comando: dict[int, comandos.Comando] = {} + for c in cadenas: + if es_texto_visible(c, llamadas): + elegidas.append(c) + elif (cmd := comando_visible(c)) is not None: + elegidas.append(c) + en_comando[c.inicio] = cmd + vistas = {c.inicio for c in elegidas} + elegidas += [c for c in textos_armados(src, cadenas, flujo, tablas) if c.inicio not in vistas] + propias = flujo_js.funciones_de_texto(src, cadenas, patron, lambda c: _es_sumidero(c, llamadas)) + if propias: # funciones del propio script cuyo parámetro solo se muestra + vistas = {c.inicio for c in elegidas} + elegidas += [c for c in cadenas if c.inicio not in vistas and es_texto_de_funcion(c, propias)] + if self.exhaustivo: + vistas = {c.inicio for c in elegidas} + elegidas += [ + c for c in cadenas if c.inicio not in vistas and parece_frase_sin_elegir(c) and _plantilla_segura(c) + ] if elegidas: - self._scripts.append(_Script(ruta, src, cadenas, elegidas)) - return [Pieza(c.texto, f"script: {c.funcion}") for s in self._scripts for c in s.elegidas] + self._scripts.append(_Script(ruta, src, cadenas, elegidas, en_comando)) + vistas = {c.inicio for c in elegidas} + frases = [c.texto for c in cadenas if c.inicio not in vistas and parece_frase_sin_elegir(c)] + if frases: + self.sin_elegir.append((ruta, frases)) + ajenos = [c.texto for c in cadenas if c.inicio not in vistas and frase_en_comando_ajeno(c)] + if ajenos: + self.comandos_ajenos.append((ruta, ajenos)) + return [ + Pieza(t, f"script: {c.funcion or 'mensaje'}") + for s in self._scripts + for c in s.elegidas + for t in s.unidades(c) + ] def escribir(self, traducciones: Mapping[str, str]) -> Resumen: resumen = Resumen() for s in self._scripts: - nuevos = { - c.inicio: traducciones[c.texto] - for c in s.elegidas - if c.texto in traducciones and _seguro(c, traducciones[c.texto]) - } + nuevos = {c.inicio: n for c in s.elegidas if (n := s.nuevo(c, traducciones)) is not None} resumen.traducidos += len(nuevos) resumen.pendientes += len(s.elegidas) - len(nuevos) datos = javascript.reemplazar(s.src, s.cadenas, nuevos).encode("utf-8") diff --git a/src/traductor_es/nucleo/memoria.py b/src/traductor_es/nucleo/memoria.py index 5a76860..611470b 100644 --- a/src/traductor_es/nucleo/memoria.py +++ b/src/traductor_es/nucleo/memoria.py @@ -45,6 +45,13 @@ def __init__(self, ruta: Path | str) -> None: self.ruta.parent.mkdir(parents=True, exist_ok=True) self._lock = threading.Lock() self._db = sqlite3.connect(str(ruta), check_same_thread=False) + try: + self._crear_tablas() + except sqlite3.Error: # archivo dañado o bloqueado: no se deja la conexión abierta + self._db.close() + raise + + def _crear_tablas(self) -> None: self._db.execute( """CREATE TABLE IF NOT EXISTS traducciones ( origen TEXT NOT NULL, dialecto TEXT NOT NULL, ambito TEXT NOT NULL, diff --git a/src/traductor_es/proceso.py b/src/traductor_es/proceso.py index c965bca..25e68dc 100644 --- a/src/traductor_es/proceso.py +++ b/src/traductor_es/proceso.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sqlite3 import threading from collections.abc import Callable, Sequence from dataclasses import dataclass, field @@ -11,11 +12,12 @@ from . import configuracion from .fuentes.instancia import Instancia -from .modulos import libros, misiones, mods, scripts +from .modulos import datos, libros, misiones, mods, runtime, scripts from .modulos.base import Contexto, Modulo, Resumen +from .modulos.cobertura import Hallazgo, hallazgos_de_comandos, hallazgos_de_scripts, revisar_cobertura from .modulos.mods import es_nombre from .motores import fabrica -from .motores.base import Motor +from .motores.base import ErrorFatal, Motor from .motores.compatible import Compatible from .motores.gemini import PRECIOS, Gemini from .nucleo.dialectos import DIALECTO_POR_DEFECTO, VARIANTES, nombre_archivo_lang @@ -56,6 +58,8 @@ class Informe: revision_automatica: revision.ResumenRevision | None = None pack_activado: list[str] = field(default_factory=list[str]) fallidos: list[str] = field(default_factory=list[str]) #: grupos que el análisis no pudo leer + #: Lo que queda en inglés aunque se traduzca todo, y por qué (ver `modulos/cobertura.py`) + cobertura: list[Hallazgo] = field(default_factory=list[Hallazgo]) @property def a_traducir(self) -> int: @@ -110,13 +114,17 @@ def __init__( usar_lotes: bool = False, contexto: str = "", revisar: bool = False, + texto_java: bool = False, + scripts_exhaustivo: bool = False, ) -> None: self.instancia = Instancia(carpeta) self.revisar = revisar + self.texto_java = texto_java + self.scripts_exhaustivo = scripts_exhaustivo self.registro = registro self.grupos = [g for g in GRUPOS if g in grupos] self._memoria_propia = memoria is None - self.memoria = memoria or Memoria(configuracion.ruta_memoria()) + self.memoria = memoria or self._abrir_memoria() self.glosario = Glosario() self.ctx = Contexto( self.instancia, Respaldo(self.instancia.juego), dialecto, VARIANTES, registro, PackSalida(self.instancia) @@ -154,19 +162,40 @@ def modulos(self) -> list[tuple[str, Modulo]]: lista: list[tuple[str, Modulo]] = [] if "mods" in self.grupos: lista.append(("mods", mods.Mods(self.ctx))) + if self.texto_java: # texto escrito en el código de los mods: lo muestra el mod de apoyo + lista.append(("mods", runtime.TextoDeJava(self.ctx))) if "libros" in self.grupos: lista.append(("libros", libros.Libros(self.ctx))) if "misiones" in self.grupos: lista += [("misiones", m) for m in misiones.todos(self.ctx)] if "scripts" in self.grupos: - lista.append(("scripts", scripts.ScriptsKubeJS(self.ctx))) + lista += [ + ("scripts", scripts.ScriptsKubeJS(self.ctx, self.scripts_exhaustivo)), + ("scripts", datos.Datos(self.ctx)), + ] return [(g, m) for g, m in lista if m.detectar()] # ------------------------------------------------------------------ acciones + def _abrir_memoria(self) -> Memoria: + """La memoria de traducciones del usuario. Si el archivo está dañado, dice cuál es y no falla a medias.""" + ruta = configuracion.ruta_memoria() + try: + return Memoria(ruta) + except sqlite3.Error as e: + self.instancia.cerrar() + raise ErrorFatal( + f"La memoria de traducciones está dañada ({e}). Muévela o bórrala y vuelve a intentarlo: {ruta}" + ) from e + + def que_queda(self, java: bool = False, progreso: Callable[[int, int], None] | None = None) -> list[Hallazgo]: + """Lo que quedaría en inglés. Solo lee. `java`: también el texto escrito en el código de los mods (tarda).""" + return revisar_cobertura(self.ctx, java, progreso or (lambda _a, _b: None)) + def analizar(self) -> Informe: """Cuenta lo que hay que traducir. Solo lee: no escribe nada en la instancia.""" self._preparar_glosario() informe = Informe() + scripts_leidos: list[scripts.ScriptsKubeJS] = [] for grupo, modulo in self.modulos(): try: piezas = modulo.recolectar() @@ -175,13 +204,32 @@ def analizar(self) -> Informe: if grupo not in informe.fallidos: informe.fallidos.append(grupo) continue + if isinstance(modulo, scripts.ScriptsKubeJS): + scripts_leidos.append(modulo) pendientes = self.traductor.pendientes([p.texto for p in piezas], modulo.ambito) informe.modulos.append( InformeModulo(grupo, modulo.nombre, modulo.describir(), len({p.texto for p in piezas}), len(pendientes), sum(len(t) for t in pendientes)) ) # fmt: skip + informe.avisos.extend(self._avisos_de_cobertura(informe, scripts_leidos)) return informe + def _avisos_de_cobertura(self, informe: Informe, scripts_leidos: list[scripts.ScriptsKubeJS]) -> list[str]: + """Lo barato de saber antes de traducir: frases de scripts que parecen texto y no se tocan. + + Se apoya en lo que el análisis ya leyó y nunca lo detiene. + """ + for s in scripts_leidos: + informe.cobertura += hallazgos_de_scripts(self.instancia.juego, s.sin_elegir) + informe.cobertura += hallazgos_de_comandos(self.instancia.juego, s.comandos_ajenos) + if not informe.cobertura: + return [] + return [ + f"{sum(h.cantidad for h in informe.cobertura)} frases de {len(informe.cobertura)} lugares de los scripts " + "no se traducen (parecen texto pero puede ser lógica, o van dentro de un comando de otro mod). " + "Detalle con «modpackes cobertura»." + ] + def ejecutar(self, activar: bool = True, poner_idioma: bool = True) -> Informe: self._preparar_glosario() informe = Informe() diff --git a/src/traductor_es/salida/exportar.py b/src/traductor_es/salida/exportar.py index 5f4b97e..05198f4 100644 --- a/src/traductor_es/salida/exportar.py +++ b/src/traductor_es/salida/exportar.py @@ -6,10 +6,12 @@ import zipfile from pathlib import Path +from ..modulos.datos import clasificar, entradas_del_zip from .copias import Respaldo from .resource_pack import NOMBRE -#: Lo que lee el servidor: misiones y scripts de servidor. Los client_scripts los ejecuta cada jugador. +#: Lo que lee el servidor: misiones y scripts de servidor, más los datos (logros y funciones) donde estén. +#: Los client_scripts los ejecuta cada jugador. _DEL_SERVIDOR = ("config/", "kubejs/server_scripts/", "kubejs/startup_scripts/", "patchouli_books/") LEEME_KIT = """KIT DE TRADUCCIÓN PARA SERVIDOR @@ -34,6 +36,19 @@ def _agregar_carpeta(zf: zipfile.ZipFile, carpeta: Path, prefijo: str = "") -> i return n +def _es_dato_del_servidor(juego: Path, relativa: str) -> bool: + """Funciones y logros de un datapack los lee el servidor; los tips son de cliente. + + Un `.zip` (datapack comprimido) cuenta por lo que lleva dentro, no por su propia ruta. + """ + ruta = Path(relativa) + if ruta.suffix.lower() == ".zip": + comprimido = juego / ruta + return comprimido.is_file() and any(f.nombre != "tip" for _, f in entradas_del_zip(comprimido)) + formato = clasificar(ruta) + return formato is not None and formato.nombre != "tip" + + def exportar_pack(juego: Path, destino: Path) -> int: """El resource pack de la traducción como .zip listo para compartir. Devuelve cuántos archivos lleva.""" pack = juego / "resourcepacks" / NOMBRE @@ -47,7 +62,7 @@ def exportar_pack(juego: Path, destino: Path) -> int: def exportar_kit_servidor(juego: Path, destino: Path) -> int: """Kit para servidores: archivos traducidos que lee el servidor, el resource pack y unas instrucciones.""" respaldo = Respaldo(juego) - archivos = [r for r in respaldo.registrados() if r.startswith(_DEL_SERVIDOR)] + archivos = [r for r in respaldo.registrados() if r.startswith(_DEL_SERVIDOR) or _es_dato_del_servidor(juego, r)] pack = juego / "resourcepacks" / NOMBRE if not archivos and not (pack / "pack.mcmeta").is_file(): raise ValueError("Todavía no hay nada traducido para el servidor: traduce primero.") diff --git a/src/traductor_es/salida/mod_apoyo.py b/src/traductor_es/salida/mod_apoyo.py new file mode 100644 index 0000000..095a869 --- /dev/null +++ b/src/traductor_es/salida/mod_apoyo.py @@ -0,0 +1,183 @@ +"""El mod de apoyo (ModpackES Runtime): qué versión le toca a una instancia y cómo ponerlo o quitarlo de `mods/`. + +El jar se construye aparte (`mod-runtime/`) y viaja dentro de la aplicación, en `traductor_es/recursos/mods/`. +La app solo lo copia cuando el jugador lo pide y solo lo quita si es uno de los suyos. +""" + +from __future__ import annotations + +import re +import shutil +from importlib import resources +from pathlib import Path + +from ..fuentes.instancia import Instancia + +PREFIJO = "modpackes_runtime-" +#: (loader, versión de Minecraft) para los que hay mod de apoyo. Cada entrada es un proyecto en `mod-runtime/`. +SOPORTADOS: dict[tuple[str, tuple[int, ...]], str] = { + ("forge", (1, 16, 5)): "forge-1.16.5", + ("forge", (1, 18, 2)): "forge-1.18.2", + ("forge", (1, 19, 2)): "forge-1.19.2", + ("forge", (1, 20, 1)): "forge-1.20.1", + ("neoforge", (1, 20, 1)): "forge-1.20.1", # NeoForge 1.20.1 es un fork de Forge y carga sus mods + # NeoForge solo publicó 1.21, 1.21.1, 1.21.3, 1.21.4, 1.21.5, 1.21.8, 1.21.10 y 1.21.11 en esa serie + ("neoforge", (1, 21, 0)): "neoforge-1.21.1", + ("neoforge", (1, 21, 1)): "neoforge-1.21.1", + ("neoforge", (1, 21, 3)): "neoforge-1.21.1", + ("neoforge", (1, 21, 4)): "neoforge-1.21.4", + ("neoforge", (1, 21, 5)): "neoforge-1.21.5", + ("neoforge", (1, 21, 8)): "neoforge-1.21.5", + ("neoforge", (1, 21, 10)): "neoforge-1.21.5", + ("neoforge", (1, 21, 11)): "neoforge-1.21.11", + ("fabric", (1, 19, 4)): "fabric-1.19.4", + # Un jar sirve para varias versiones cuando el mismo código compila y pasa las pruebas contra cada una de ellas + ("fabric", (1, 20, 0)): "fabric-1.20.1", + ("fabric", (1, 20, 1)): "fabric-1.20.1", + ("fabric", (1, 20, 2)): "fabric-1.20.1", + ("fabric", (1, 20, 3)): "fabric-1.20.4", + ("fabric", (1, 20, 4)): "fabric-1.20.4", + ("fabric", (1, 20, 5)): "fabric-1.20.6", + ("fabric", (1, 20, 6)): "fabric-1.20.6", + ("fabric", (1, 21, 0)): "fabric-1.21.1", + ("fabric", (1, 21, 1)): "fabric-1.21.1", + ("fabric", (1, 21, 2)): "fabric-1.21.4", + ("fabric", (1, 21, 3)): "fabric-1.21.4", + ("fabric", (1, 21, 4)): "fabric-1.21.4", + ("fabric", (1, 21, 5)): "fabric-1.21.5", + ("fabric", (1, 21, 6)): "fabric-1.21.5", + ("fabric", (1, 21, 7)): "fabric-1.21.5", + ("fabric", (1, 21, 8)): "fabric-1.21.5", + ("fabric", (1, 21, 9)): "fabric-1.21.5", + ("fabric", (1, 21, 10)): "fabric-1.21.5", + ("fabric", (1, 21, 11)): "fabric-1.21.11", + ("fabric", (26, 1, 0)): "fabric-26.1", + ("fabric", (26, 1, 1)): "fabric-26.1", + ("fabric", (26, 1, 2)): "fabric-26.1", + ("fabric", (26, 2, 0)): "fabric-26.1", + ("fabric", (26, 3, 0)): "fabric-26.1", +} +#: Quilt carga los mods de Fabric con Quilted Fabric API, que existe para estas versiones (no para 1.20.3 ni 1.20.5). +_VERSIONES_DE_QUILT: tuple[tuple[int, ...], ...] = ( + (1, 19, 4), (1, 20, 0), (1, 20, 1), (1, 20, 2), (1, 20, 4), (1, 20, 6), (1, 21, 0), (1, 21, 1), +) # fmt: skip +SOPORTADOS.update({("quilt", v): SOPORTADOS[("fabric", v)] for v in _VERSIONES_DE_QUILT}) +#: Combinaciones que no tendrán mod de apoyo y por qué (para decírselo al jugador en vez de «todavía no»). +_SIN_EVENTO_DE_CHAT = ( + "Fabric API no tiene en esa versión un evento para los mensajes que recibe el cliente, " + "y sin él el mod de apoyo no podría cambiar el chat. Con Fabric 1.19.4 en adelante sí." +) +IMPOSIBLES: dict[tuple[str, tuple[int, ...]], str] = { + ("fabric", (1, 16, 5)): _SIN_EVENTO_DE_CHAT, + ("fabric", (1, 18, 2)): _SIN_EVENTO_DE_CHAT, + ("fabric", (1, 19, 2)): _SIN_EVENTO_DE_CHAT, +} +_NOMBRE = re.compile(rf"{re.escape(PREFIJO)}(?P[a-z]+-[\d.]+)-(?P[\w.+-]+)\.jar") + + +def _carpeta_de_jars() -> Path: + return Path(str(resources.files("traductor_es") / "recursos" / "mods")) + + +def destino_de(instancia: Instancia) -> str | None: + """«forge-1.20.1» si hay mod de apoyo para esta instancia; None si no.""" + return SOPORTADOS.get((instancia.loader or "", instancia.version.partes)) + + +def _sin_mod(instancia: Instancia) -> str: + """Por qué no hay mod de apoyo para esta instancia, en una frase.""" + razon = IMPOSIBLES.get((instancia.loader or "", instancia.version.partes)) + if razon is not None: + return f"No hay mod de apoyo para Minecraft {instancia.version} con {instancia.loader}: {razon}" + loader = instancia.loader or "sin loader" + return f"Todavía no hay mod de apoyo para Minecraft {instancia.version} con {loader}." + + +def _nota_de_compatibilidad(instancia: Instancia) -> str | None: + """Cuando el jar es de otro loader que el de la instancia (y no se ha probado en él), se dice.""" + if instancia.loader == "quilt": + return ( + "Es el mod de Fabric: Quilt lo carga con Quilted Fabric API (necesita que esté instalada) y no se ha " + "probado en Quilt." + ) + if instancia.loader == "neoforge" and instancia.version.partes == (1, 20, 1): + return "Es el mod de Forge 1.20.1: NeoForge 1.20.1 es un fork de Forge y debería cargarlo; no se ha probado." + return None + + +def jar_empaquetado(instancia: Instancia, carpeta: Path | None = None) -> Path | None: + """El jar más nuevo del mod de apoyo para esta instancia, si viene con la aplicación.""" + destino = destino_de(instancia) + carpeta = carpeta or _carpeta_de_jars() + if destino is None or not carpeta.is_dir(): + return None + candidatos = [p for p in carpeta.glob(f"{PREFIJO}{destino}-*.jar") if p.is_file()] + return max(candidatos, key=lambda p: p.name, default=None) + + +def instalados(instancia: Instancia) -> list[Path]: + """Los jars de ModpackES Runtime que ya están en `mods/` (de cualquier versión).""" + carpeta = instancia.carpeta_mods + return sorted(p for p in carpeta.glob(f"{PREFIJO}*.jar") if _NOMBRE.fullmatch(p.name)) if carpeta.is_dir() else [] + + +def estado(instancia: Instancia, carpeta_jars: Path | None = None) -> list[str]: + """Lo que hay que saber del mod de apoyo en esta instancia, en frases (no abre el juego).""" + destino = destino_de(instancia) + if destino is None: + return [f"{_sin_mod(instancia)} El texto del código Java seguirá en inglés."] + lineas = [f"Hay mod de apoyo para esta instancia ({destino})."] + nota = _nota_de_compatibilidad(instancia) + if nota: + lineas.append(nota) + lineas.append( + "Esta copia de la aplicación lo trae." + if jar_empaquetado(instancia, carpeta_jars) + else "Esta copia de la aplicación NO lo trae." + ) + puestos = instalados(instancia) + lineas.append(f"En mods/: {', '.join(p.name for p in puestos)}." if puestos else "No está instalado en mods/.") + ajenos = [p.name for p in puestos if not p.name.startswith(f"{PREFIJO}{destino}-")] + if ajenos: + lineas.append( + f"Ojo: {', '.join(ajenos)} no es el de esta versión ({destino}). " + "Pulsa «Instalar el mod de apoyo» para cambiarlo." + ) + tablas = sorted( + (instancia.juego / "resourcepacks" / "Traduccion_ES" / "assets" / "modpackes_runtime" / "textos").glob("*.json") + ) + lineas.append( + f"Tabla de textos en el resource pack: {len(tablas)} variantes." + if tablas + else "Todavía no hay tabla de textos: traduce con la opción activada." + ) + if puestos and not ajenos and tablas: + lineas.append("Todo listo: abre el juego en español.") + return lineas + + +def instalar(instancia: Instancia, carpeta_jars: Path | None = None) -> Path: + """Copia el mod de apoyo a `mods/` y quita sus versiones anteriores. ValueError si no hay uno.""" + destino = destino_de(instancia) + if destino is None: + raise ValueError(f"{_sin_mod(instancia)} El texto escrito en el código de los mods seguirá en inglés.") + origen = jar_empaquetado(instancia, carpeta_jars) + if origen is None: + raise ValueError( + f"Esta copia de la aplicación no trae el mod de apoyo ({destino}). Descarga la versión completa." + ) + instancia.carpeta_mods.mkdir(parents=True, exist_ok=True) + final = instancia.carpeta_mods / origen.name + shutil.copyfile(origen, final) # primero el nuevo: si la copia falla, el mod que había sigue ahí + for viejo in instalados(instancia): + if viejo != final: + viejo.unlink() + return final + + +def quitar(instancia: Instancia) -> list[Path]: + """Quita de `mods/` los jars del mod de apoyo. No toca ningún otro mod.""" + quitados = instalados(instancia) + for ruta in quitados: + ruta.unlink() + return quitados diff --git a/src/traductor_es/salida/opciones.py b/src/traductor_es/salida/opciones.py index bc2425d..f08f931 100644 --- a/src/traductor_es/salida/opciones.py +++ b/src/traductor_es/salida/opciones.py @@ -53,7 +53,7 @@ def activar(juego: Path, nombre_pack: str, idioma: str | None) -> list[str]: return [] crudo = ruta.read_bytes() salto = "\r\n" if b"\r\n" in crudo else "\n" - lineas = crudo.decode("utf-8", errors="replace").splitlines() + lineas = crudo.decode("utf-8", errors="surrogateescape").splitlines() # lo que no es UTF-8 se conserva igual cambios: list[str] = [] vio_packs = vio_idioma = False for i, linea in enumerate(lineas): @@ -78,6 +78,6 @@ def activar(juego: Path, nombre_pack: str, idioma: str | None) -> list[str]: cambios.append(f"idioma del juego: {idioma}") if cambios: temporal = ruta.with_name("options.txt.tmp") - temporal.write_bytes((salto.join(lineas) + salto).encode("utf-8")) + temporal.write_bytes((salto.join(lineas) + salto).encode("utf-8", errors="surrogateescape")) temporal.replace(ruta) return cambios diff --git a/src/traductor_es/salida/resource_pack.py b/src/traductor_es/salida/resource_pack.py index f95e241..0ae8223 100644 --- a/src/traductor_es/salida/resource_pack.py +++ b/src/traductor_es/salida/resource_pack.py @@ -18,6 +18,16 @@ NOMBRE = "Traduccion_ES" #: Un segmento de ruta seguro: sin barras invertidas, dos puntos ni caracteres que Windows no admite. _PARTE_VALIDA = re.compile(r"[^\\/:*?\"<>|\x00-\x1f]+") +#: Nombres de dispositivo de Windows (CON, NUL, COM1…): no se pueden usar como carpeta ni archivo. +_RESERVADOS = frozenset( + {"CON", "PRN", "AUX", "NUL", *(f"COM{n}" for n in range(1, 10)), *(f"LPT{n}" for n in range(1, 10))} +) + + +def _parte_segura(parte: str) -> bool: + if not _PARTE_VALIDA.fullmatch(parte) or parte in (".", "..") or parte != parte.rstrip(". "): + return False # Windows quita los puntos y espacios del final: «a.» y «a» serían la misma carpeta + return parte.split(".", maxsplit=1)[0].upper() not in _RESERVADOS class PackSalida: @@ -30,7 +40,7 @@ def __init__(self, instancia: Instancia, nombre: str = NOMBRE) -> None: def _ruta(self, relativa: str) -> Path: """Ruta dentro del pack. Rechaza todo lo que pueda salir de él (también trucos de Windows).""" partes = relativa.split("/") - if not relativa or any(not _PARTE_VALIDA.fullmatch(p) or p in (".", "..") for p in partes): + if not relativa or any(not _parte_segura(p) for p in partes): raise ValueError(f"Ruta fuera del pack: {relativa!r}") ruta = self.raiz.joinpath(*partes) if not ruta.resolve().is_relative_to(self.raiz.resolve()): diff --git a/src/traductor_es/ui/informe.py b/src/traductor_es/ui/informe.py new file mode 100644 index 0000000..8a02873 --- /dev/null +++ b/src/traductor_es/ui/informe.py @@ -0,0 +1,31 @@ +"""El cuadro «Qué queda en inglés»: el informe por categorías, con un botón para guardarlo.""" + +from __future__ import annotations + +from collections.abc import Callable + +from PySide6.QtWidgets import QDialog, QDialogButtonBox, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget + +from .componentes import etiqueta + + +class DialogoInforme(QDialog): + """Solo muestra el texto del informe; qué hace «Guardar…» lo decide la ventana.""" + + def __init__(self, texto: str, guardar: Callable[[], None], padre: QWidget | None = None) -> None: + super().__init__(padre) + self.setWindowTitle("Qué queda en inglés") + self.setMinimumSize(720, 460) + capa = QVBoxLayout(self) + capa.setSpacing(12) + capa.addWidget(etiqueta("Qué queda en inglés", "titulo")) + self.texto = QPlainTextEdit() + self.texto.setReadOnly(True) + self.texto.setPlainText(texto) + capa.addWidget(self.texto, 1) + botones = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + self.b_guardar = QPushButton("Guardar…") + self.b_guardar.clicked.connect(guardar) + botones.addButton(self.b_guardar, QDialogButtonBox.ButtonRole.ActionRole) + botones.rejected.connect(self.close) + capa.addWidget(botones) diff --git a/src/traductor_es/ui/ventana.py b/src/traductor_es/ui/ventana.py index f666bb9..7612ebe 100644 --- a/src/traductor_es/ui/ventana.py +++ b/src/traductor_es/ui/ventana.py @@ -47,8 +47,9 @@ from .. import AUTOR, NOMBRE, REPOSITORIO, __version__, actualizaciones, configuracion, registro from ..actualizaciones import Novedad -from ..fuentes.instancia import resolver_carpeta_juego +from ..fuentes.instancia import Instancia, resolver_carpeta_juego from ..fuentes.lanzadores import InstanciaEncontrada, buscar +from ..modulos.cobertura import Hallazgo, texto_del_informe from ..motores import fabrica from ..motores.base import Cancelado, ErrorFatal, Motor from ..motores.compatible import Compatible @@ -56,7 +57,7 @@ from ..motores.simulador import Simulador from ..nucleo.traductor import LARGO_CONTEXTO from ..proceso import GRUPOS, Informe, Proceso -from ..salida import exportar +from ..salida import exportar, mod_apoyo from . import correcciones, iconos, tema from .bienvenida import TarjetaBienvenida from .componentes import ( @@ -70,6 +71,7 @@ icono_en_boton, seguir_tema, ) +from .informe import DialogoInforme from .instancias import Lector, ListaInstancias, traducida from .motor import DialogoMotor @@ -159,12 +161,16 @@ def ejecutar(self) -> None: usar_lotes=self.opciones["lotes"], contexto=self.opciones["contexto"], revisar=self.opciones["revisar"], + texto_java=self.opciones["texto_java"], + scripts_exhaustivo=self.opciones["exhaustivo"], ) as p: self.registro.emit(f"Minecraft {p.instancia.version} · {p.instancia.loader or '¿loader?'}") if self.accion == "analizar": resultado: object = p.analizar() elif self.accion == "traducir": resultado = p.ejecutar(activar=self.opciones["activar"], poner_idioma=self.opciones["idioma"]) + elif self.accion == "cobertura": + resultado = p.que_queda(java=True, progreso=self.progreso.emit) else: resultado = p.restaurar() self.terminado.emit(resultado) @@ -216,6 +222,8 @@ def __init__( self._fase = "" self._clave_lista = False self.ultimo_informe: Informe | None = None + self.ultima_cobertura: list[Hallazgo] = [] + self._dialogo_informe: DialogoInforme | None = None self._preferencias: dict[str, Any] = configuracion.leer_preferencias() self._motor_anterior: str | None = None self._construir() @@ -260,7 +268,13 @@ def _construir(self) -> None: self.c_motor.setCurrentIndex(max(0, self.c_motor.findData(self._preferencias.get("motor", fabrica.GEMINI)))) self.c_motor.currentIndexChanged.connect(self._al_cambiar_motor) self._al_cambiar_motor() - for senal in (self.c_lotes.toggled, self.c_revisar.toggled, self.c_activar.toggled): + for senal in ( + self.c_lotes.toggled, + self.c_revisar.toggled, + self.c_activar.toggled, + self.c_java.toggled, + self.c_exhaustivo.toggled, + ): senal.connect(self._al_cambiar_opciones) self.c_hilos.valueChanged.connect(self._al_cambiar_opciones) self.c_dialecto.currentIndexChanged.connect(self._al_cambiar_opciones) @@ -451,6 +465,22 @@ def _opciones_avanzadas(self) -> QWidget: ) self.c_revisar.setChecked(bool(self._preferencias.get("revisar", False))) formulario.addRow("", self.c_revisar) + self.c_java = QCheckBox("Incluir texto escrito en el código de los mods (necesita el mod de apoyo)") + self.c_java.setToolTip( + "Algunos mods tienen mensajes escritos en su código y un resource pack no los alcanza. Esto los " + "traduce a una tabla que lee el mod de apoyo «ModpackES Runtime» (Más ▸ Instalar el mod de apoyo). " + "Por ahora existe para Forge 1.16.5, 1.18.2, 1.19.2 y 1.20.1, NeoForge 1.20.1 y de 1.21 a 1.21.11, " + "y Fabric 1.19.4, de 1.20 a 1.20.6, de 1.21 a 1.21.11 y de 26.1 a 26.3. En Quilt usa los mods de Fabric." + ) + self.c_java.setChecked(bool(self._preferencias.get("texto_java", False))) + formulario.addRow("", self.c_java) + self.c_exhaustivo = QCheckBox("Scripts: traducir toda frase, aunque no se sepa dónde se muestra") + self.c_exhaustivo.setToolTip( + "Puede tocar lógica de los scripts de KubeJS (una frase que se compara o se guarda). Todo queda con copia " + "de seguridad y se puede restaurar. Mira antes «Más ▸ Qué queda en inglés»." + ) + self.c_exhaustivo.setChecked(bool(self._preferencias.get("exhaustivo", False))) + formulario.addRow("", self.c_exhaustivo) self.c_hilos = QSpinBox() self.c_hilos.setRange(1, 16) self.c_hilos.setSuffix(" pedidos a la vez") @@ -498,6 +528,15 @@ def agregar(texto: str, nombre_icono: str, funcion: Callable[[], object]) -> QAc self.a_correcciones = agregar("Correcciones…", "pencil", self.abrir_correcciones) self.a_correcciones.setToolTip("Buscar y corregir a mano traducciones de la memoria") + self.a_cobertura = agregar("Qué queda en inglés", "circle-alert", lambda: self.lanzar("cobertura")) + self.a_cobertura.setToolTip("Lista el texto que no se puede traducir y por qué (solo lee, no cambia nada)") + self.a_guardar_informe = agregar("Guardar el informe de lo que queda…", "file-text", self._guardar_informe) + self.a_comprobar_apoyo = agregar("Comprobar el mod de apoyo", "circle-check", self._comprobar_apoyo) + self.a_apoyo = agregar("Instalar el mod de apoyo", "package", self._instalar_apoyo) + self.a_apoyo.setToolTip( + "Pone ModpackES Runtime en la carpeta mods: muestra en español el texto del código de los mods" + ) + self.a_quitar_apoyo = agregar("Quitar el mod de apoyo", "undo-2", self._quitar_apoyo) self.a_restaurar = agregar("Restaurar el original…", "undo-2", self._confirmar_restaurar) self.a_exportar = agregar("Exportar pack…", "package", lambda: self._exportar(kit=False)) self.a_kit = agregar("Kit de servidor…", "server", lambda: self._exportar(kit=True)) @@ -833,6 +872,8 @@ def opciones(self) -> dict[str, Any]: "idioma": self.c_activar.isChecked(), "contexto": self.e_contexto.text().strip(), "revisar": self.c_revisar.isChecked(), + "texto_java": self.c_java.isChecked(), + "exhaustivo": self.c_exhaustivo.isChecked(), } def _confirmar_traducir(self) -> None: @@ -889,7 +930,11 @@ def lanzar(self, accion: str) -> None: self.barra.setMaximum(0) # sin total todavía: barra en movimiento self._inicio = None self._fase = "" - self.estado.setText({"analizar": "Analizando…", "traducir": "Preparando…"}.get(accion, "Restaurando…")) + self.estado.setText( + {"analizar": "Analizando…", "traducir": "Preparando…", "cobertura": "Buscando texto en inglés…"}.get( + accion, "Restaurando…" + ) + ) self._accion = accion self._antes = self._estado self._estado = "trabajando" @@ -923,7 +968,10 @@ def _progreso(self, hechos: int, total: int) -> None: self._inicio = (ahora, total) # cada módulo cuenta desde cero self.barra.setMaximum(max(total, 1)) self.barra.setValue(hechos) - texto = f"Traduciendo{f' {self._fase}' if self._fase else ''}… {_miles(hechos)} de {_miles(total)}" + if self._accion == "cobertura": + texto = f"Leyendo los mods… {_miles(hechos)} de {_miles(total)}" + else: + texto = f"Traduciendo{f' {self._fase}' if self._fase else ''}… {_miles(hechos)} de {_miles(total)}" pasado = ahora - self._inicio[0] if 0 < hechos < total and pasado > 3: texto += f" · quedan {_restante(pasado / hechos * (total - hechos))}" @@ -956,6 +1004,9 @@ def _terminar(self, resultado: object) -> None: self.estado.setText("¡Listo! Abre el juego: ya está en español.") self.aviso.mostrar("Traducción terminada. Abre Minecraft para verla.", "exito") self._marcar_traducida() + elif accion == "cobertura": + self._estado = self._antes # no cambia nada en la instancia: el botón principal sigue como estaba + self._mostrar_cobertura(cast("list[Hallazgo]", resultado)) else: self._olvidar_analisis() # el texto volvió al original: lo contado antes ya no vale n = len(resultado) if isinstance(resultado, list) else 0 # pyright: ignore[reportUnknownArgumentType] @@ -964,6 +1015,71 @@ def _terminar(self, resultado: object) -> None: self._al_cambiar_opciones() self.escribir(self.estado.text()) + def _con_instancia(self, accion: Callable[[Instancia], str]) -> None: + carpeta = self.carpeta() + if not carpeta: + self.avisar("Elige primero una instancia.") + return + try: + with Instancia(carpeta) as inst: + mensaje = accion(inst) + except (ValueError, OSError) as e: + self.avisar(str(e), "aviso", anotar=False) + return + self.escribir(mensaje) + self.aviso.mostrar(mensaje, "exito") + + def _instalar_apoyo(self) -> None: + self._con_instancia( + lambda inst: ( + f"Mod de apoyo instalado: {mod_apoyo.instalar(inst).name}. Abre el juego para que empiece a actuar." + ) + ) + + def _quitar_apoyo(self) -> None: + def quitar(inst: Instancia) -> str: + quitados = mod_apoyo.quitar(inst) + return "Quité: " + ", ".join(p.name for p in quitados) if quitados else "No había mod de apoyo instalado." + + self._con_instancia(quitar) + + def _comprobar_apoyo(self) -> None: + self._con_instancia(lambda inst: "\n".join(mod_apoyo.estado(inst))) + + def _guardar_informe(self, destino: str | None = None) -> None: + if not self.ultima_cobertura: + self.avisar("Primero usa «Más ▸ Qué queda en inglés».") + return + if destino is None: + destino, _ = QFileDialog.getSaveFileName( + self, "Guardar el informe", str(Path.home() / "Que_queda_en_ingles.txt"), "Texto (*.txt)" + ) + if not destino: + return + try: + Path(destino).write_text(texto_del_informe(self.ultima_cobertura), encoding="utf-8") + except OSError as e: + self.avisar(f"No pude guardar el informe: {e}", "error") + return + self.aviso.mostrar(f"Informe guardado: {destino}", "exito") + + def _mostrar_cobertura(self, hallazgos: list[Hallazgo]) -> None: + self.ultima_cobertura = hallazgos + if not hallazgos: + self.estado.setText("No encontré texto que se quede en inglés.") + return + self._dialogo_informe = DialogoInforme(texto_del_informe(hallazgos), self._guardar_informe, self) + self._dialogo_informe.show() + self.estado.setText(f"Quedan {len(hallazgos)} lugares con texto en inglés: el detalle está en Detalles.") + self.escribir("Texto que se queda en inglés aunque traduzcas todo:") + for h in hallazgos: + self.escribir(f"· [{h.categoria}] {h}") + self.escribir( + "Lo de «java» está escrito en el código del mod: un resource pack no lo alcanza. " + "Lo de «script» puede ser lógica y no un mensaje." + ) + self.detalles.fijar_abierta(True) + def _marcar_traducida(self) -> None: carpeta = self.carpeta() tarjeta = self.lista.actual() @@ -1085,7 +1201,8 @@ def _guardar_preferencias(self, carpeta: str) -> None: "carpeta": carpeta, "dialecto": o["dialecto"], "grupos": o["grupos"], "motor": o["motor"], "modelo": modelos.get(fabrica.GEMINI, MODELO_POR_DEFECTO), "modelos": modelos, "direcciones": direcciones, "lotes": o["lotes"], "hilos": o["hilos"], "contextos": contextos, - "revisar": o["revisar"], "avisar_version": self.c_avisar.isChecked(), + "revisar": o["revisar"], "texto_java": o["texto_java"], "exhaustivo": o["exhaustivo"], + "avisar_version": self.c_avisar.isChecked(), "tema": self._preferencias.get("tema", "sistema"), "bienvenida_vista": bool(self._preferencias.get("bienvenida_vista", False)), } # fmt: skip diff --git a/tests/test_cobertura.py b/tests/test_cobertura.py new file mode 100644 index 0000000..2c85167 --- /dev/null +++ b/tests/test_cobertura.py @@ -0,0 +1,159 @@ +"""Informe de lo que queda en inglés: frases de scripts sin elegir y texto escrito en el código de los mods.""" + +import struct +from pathlib import Path + +from traductor_es.fuentes.instancia import Instancia +from traductor_es.modulos.base import Contexto +from traductor_es.modulos.cobertura import constantes_utf8, revisar_cobertura +from traductor_es.motores.simulador import Simulador +from traductor_es.proceso import Proceso +from traductor_es.salida.copias import Respaldo + +from . import fabrica + +SCRIPT = """PlayerEvents.loggedIn(event => { + event.player.tell('Welcome to the village') + let aviso = 'The market is closed today' + console.log('This should be a debug message here') + if (event.player.name == 'Some Long Name Here') return +}) +ServerEvents.recipes(event => { event.shapeless('minecraft:stone', ['Not A Real Recipe Name']) }) +""" + + +def _clase(*constantes: str) -> bytes: + """Un .class mínimo: solo la cabecera y la tabla de constantes con esas cadenas (más un entero entre medias).""" + cuerpo = b"".join(b"\x01" + struct.pack(">H", len(c.encode())) + c.encode() for c in constantes[:1]) + cuerpo += b"\x03\x00\x00\x00\x07" # un Integer: el lector tiene que saltarlo bien + cuerpo += b"".join(b"\x01" + struct.pack(">H", len(c.encode())) + c.encode() for c in constantes[1:]) + cantidad = len(constantes) + 2 # las casillas empiezan en 1 + return b"\xca\xfe\xba\xbe" + struct.pack(">HHH", 0, 52, cantidad) + cuerpo + + +def test_lee_la_tabla_de_constantes_de_un_class() -> None: + assert constantes_utf8(_clase("literal", "You can't do that here!", "ñandú")) == [ + "literal", "You can't do that here!", "ñandú", + ] # fmt: skip + assert constantes_utf8(b"no es un class") == [] + assert constantes_utf8(_clase("literal")[:-3]) == [] # truncado: no se inventa nada + + +def test_cobertura_de_scripts_y_java(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + (juego / "kubejs" / "server_scripts").mkdir(parents=True) + (juego / "kubejs" / "server_scripts" / "a.js").write_text(SCRIPT, encoding="utf-8") + fabrica.jar( + juego / "mods" / "m.jar", + { + "com/m/Chat.class": _clase("literal", "You can't use this item here!", "Failed to load the config file"), + "com/m/Fabric.class": _clase("method_43470", "Click the block to open the menu"), + "com/m/Otra.class": _clase("nada", "Another English sentence here"), # no construye texto + "com/m/Spanish.class": _clase("literal", "Alacena de abedul sin corteza"), # ya está en español + }, + ) + with Instancia(juego) as inst: + sin_java = revisar_cobertura(Contexto(inst, Respaldo(juego))) + con_java = revisar_cobertura(Contexto(inst, Respaldo(juego)), java=True) + assert [(h.categoria, h.donde) for h in sin_java] == [("script", "kubejs/server_scripts/a.js")] + assert sin_java[0].cantidad == 1 # `tell` ya se traduce; recetas, comparaciones y console.log no cuentan + assert sin_java[0].ejemplos == ("The market is closed today",) + de_java = [h for h in con_java if h.categoria == "java"] + assert len(de_java) == 1 + assert set(de_java[0].ejemplos) == {"You can't use this item here!", "Click the block to open the menu"} + + +def test_analizar_avisa_sin_detener_el_analisis(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + (juego / "kubejs" / "server_scripts").mkdir(parents=True) + (juego / "kubejs" / "server_scripts" / "a.js").write_text(SCRIPT, encoding="utf-8") + with Proceso(juego, Simulador(), grupos=["scripts"]) as p: + informe = p.analizar() + assert any("1 frases de 1 lugares de los scripts" in a for a in informe.avisos) + assert informe.modulos and informe.modulos[0].grupo == "scripts" + + +def test_formatos_desconocidos_se_informan_sin_tocar(tmp_path: Path) -> None: + import json + + from traductor_es.modulos.cobertura import formatos_desconocidos + + juego = fabrica.prism(tmp_path, "1.20.1") + quiz = juego / "kubejs" / "data" / "a" / "quizzes" / "q1.json" + quiz.parent.mkdir(parents=True) + quiz.write_text(json.dumps({"title": "What do cows eat for breakfast?", "id": "x"}), encoding="utf-8") + conocido = juego / "kubejs" / "data" / "a" / "advancements" / "g.json" + conocido.parent.mkdir(parents=True) + conocido.write_text(json.dumps({"display": {"title": {"text": "A conocido title here"}}}), encoding="utf-8") + receta = juego / "kubejs" / "data" / "a" / "recipes" / "r.json" + receta.parent.mkdir(parents=True) + receta.write_text(json.dumps({"result": {"text": "Not shown to the player, a recipe"}}), encoding="utf-8") + antes = quiz.read_bytes() + hallazgos = formatos_desconocidos(juego) + assert [(h.categoria, h.donde, h.cantidad) for h in hallazgos] == [("formato", "kubejs/data/a/quizzes", 1)] + assert hallazgos[0].ejemplos == ("What do cows eat for breakfast?",) and quiz.read_bytes() == antes + + +def test_texto_y_json_del_informe() -> None: + import json + + from traductor_es.modulos.cobertura import Hallazgo, informe_en_json, texto_del_informe + + hallazgos = [ + Hallazgo("java", "m.jar", 2, "Escrito en el código", ("You can't do that!", "Ñandú ¡hola!")), + Hallazgo("script", "kubejs/a.js", 1, "Parece una frase", ("The market is closed",)), + ] + texto = texto_del_informe(hallazgos) + assert texto.index("Frases de scripts") < texto.index("Escrito en el código de los mods") # script antes que java + assert "m.jar: 2 textos" in texto and "Ñandú ¡hola!" in texto + assert texto_del_informe([]) == "No encontré texto que se quede en inglés.\n" + datos = json.loads(informe_en_json(hallazgos)) + assert datos[0] == {"categoria": "java", "donde": "m.jar", "textos": 2, "motivo": "Escrito en el código", + "ejemplos": ["You can't do that!", "Ñandú ¡hola!"]} # fmt: skip + assert "Ñandú" in informe_en_json(hallazgos) # UTF-8 sin escapar + + +#: `queue` es de otro mod (Embers Text API): la app no conoce su formato y no debe tocarlo, solo avisar. +SCRIPT_CON_COMANDOS = """ +const cola = (p, texto) => p.server.runCommandSilent(`emberstextapi queue "${p.name.string}" hotbar [bg gradient=1]${texto}`) +ServerEvents.commandRegistry(event => { + event.server.runCommandSilent(`/eta queue "${event.player.name.string}" credits Thank you for playing, we hope that you enjoyed it`) + event.server.runCommand('scoreboard objectives add Hurt minecraft.custom:minecraft.damage_dealt') + event.server.runCommand('execute as @a at @s run function foo:bar') + event.server.runCommandSilent('tellraw @a {"text":"Welcome back to the village, friend"}') + console.log('You can see this only in the log of the server') +}) +""" + + +def test_comandos_de_otros_mods_con_frase_se_avisan_y_no_se_tocan(tmp_path: Path) -> None: + from traductor_es.modulos.cobertura import hallazgos_de_comandos + from traductor_es.modulos.scripts import ScriptsKubeJS + + juego = fabrica.prism(tmp_path, "1.20.1") + ruta = juego / "kubejs" / "server_scripts" / "a.js" + ruta.parent.mkdir(parents=True) + ruta.write_text(SCRIPT_CON_COMANDOS, encoding="utf-8") + antes = ruta.read_bytes() + with Instancia(juego) as inst: + s = ScriptsKubeJS(Contexto(inst, Respaldo(juego))) + piezas = s.recolectar() + hallazgos = hallazgos_de_comandos(juego, s.comandos_ajenos) + assert [(h.categoria, h.donde, h.cantidad) for h in hallazgos] == [("comando", "kubejs/server_scripts/a.js", 1)] + assert "Thank you for playing" in hallazgos[0].ejemplos[0] + # el comando de chat (tellraw) sí se traduce; el de otro mod no entra entre lo que se traduce + assert [p.texto for p in piezas] == ["Welcome back to the village, friend"] + assert ruta.read_bytes() == antes # analizar no escribe nada + + +def test_el_informe_incluye_los_comandos_de_otros_mods(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + ruta = juego / "kubejs" / "server_scripts" / "a.js" + ruta.parent.mkdir(parents=True) + ruta.write_text(SCRIPT_CON_COMANDOS, encoding="utf-8") + with Instancia(juego) as inst: + hallazgos = revisar_cobertura(Contexto(inst, Respaldo(juego))) + assert "comando" in {h.categoria for h in hallazgos} + from traductor_es.modulos.cobertura import texto_del_informe + + assert "Comandos de otros mods" in texto_del_informe(hallazgos) diff --git a/tests/test_comandos_y_datos.py b/tests/test_comandos_y_datos.py new file mode 100644 index 0000000..0884396 --- /dev/null +++ b/tests/test_comandos_y_datos.py @@ -0,0 +1,176 @@ +"""Mensajes de chat: comandos `tellraw`/`say` en scripts de KubeJS, y logros y funciones de `kubejs/data`.""" + +import json +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from traductor_es.formatos import comandos +from traductor_es.fuentes.instancia import Instancia +from traductor_es.modulos.base import Contexto, traducir_modulo +from traductor_es.modulos.datos import Datos +from traductor_es.modulos.scripts import ScriptsKubeJS +from traductor_es.motores.simulador import Simulador +from traductor_es.nucleo.glosario import Glosario +from traductor_es.nucleo.memoria import Memoria +from traductor_es.nucleo.traductor import Traductor +from traductor_es.salida.copias import Respaldo + +from . import fabrica + +SCRIPT = r"""ServerEvents.commandRegistry(event => {}) +PlayerEvents.loggedIn(event => { + event.server.runCommandSilent('tellraw @a {"text":"Welcome to the village!","color":"gold"}') + event.server.runCommand("title @a actionbar {\"text\":\"Market opens soon\"}") + event.server.runCommand('say The festival begins') + event.server.runCommand(`execute as @a run tellraw @s ["",{"text":"Hello ${event.player.username}"}]`) + event.server.runCommandSilent('title @a times 10 70 20') + event.server.runCommandSilent('give @p minecraft:stone 2') + event.server.runCommandSilent('tellraw @a {text:"snbt not touched"}') +}) +""" + + +@pytest.fixture +def memoria() -> Iterator[Memoria]: + with Memoria(":memory:") as m: + yield m + + +@pytest.mark.parametrize( + ("texto", "prefijo", "cuerpo", "es_json"), + [ + ('tellraw @a {"text":"Hi"}', "tellraw @a ", '{"text":"Hi"}', True), + ('/tellraw @a[distance=..5, tag=x] ["Hi"]', "/tellraw @a[distance=..5, tag=x] ", '["Hi"]', True), + ('title @s subtitle {"text":"Hi"}', "title @s subtitle ", '{"text":"Hi"}', True), + ("say Hello there", "say ", "Hello there", False), + ("execute as @a run msg @s Hello", "execute as @a run msg @s ", "Hello", False), + ], +) +def test_analiza_comandos_de_chat(texto: str, prefijo: str, cuerpo: str, es_json: bool) -> None: + assert comandos.analizar(texto) == comandos.Comando(prefijo, cuerpo, es_json) + + +@pytest.mark.parametrize("texto", ["give @p stone", "title @a times 1 2 3", "title @a clear", "tellraw @a", "# say hi"]) +def test_ignora_lo_que_no_muestra_texto(texto: str) -> None: + assert comandos.analizar(texto) is None + + +def test_runcommand_traduce_solo_el_mensaje(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + ruta = juego / "kubejs" / "server_scripts" / "chat.js" + ruta.parent.mkdir(parents=True) + ruta.write_text(SCRIPT, encoding="utf-8") + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + resumen = traducir_modulo(ScriptsKubeJS(ctx), Traductor(Simulador(), memoria, Glosario())) + assert resumen.traducidos == 5 and resumen.pendientes == 0 + nuevo = ruta.read_text(encoding="utf-8") + assert """tellraw @a {"text": "«ES» Welcome to the village!", "color": "gold"}""" in nuevo + assert r"""title @a actionbar {\"text\": \"«ES» Market opens soon\"}""" in nuevo + assert "say «ES» The festival begins" in nuevo + assert "${event.player.username}" in nuevo and "execute as @a run tellraw @s " in nuevo + assert "times 10 70 20" in nuevo and "give @p minecraft:stone 2" in nuevo + assert 'tellraw @a {text:"«ES» snbt not touched"}' in nuevo # SNBT (1.21.5+): se conserva el formato + ScriptsKubeJS(ctx).restaurar() + assert ruta.read_text(encoding="utf-8") == SCRIPT + + +LOGRO = { + "display": { + "icon": {"item": "minecraft:wheat"}, + "title": {"text": "First Harvest", "color": "gold"}, + "description": "Harvest your first crop", + "frame": "task", + }, + "criteria": {"x": {"trigger": "minecraft:impossible"}}, +} +FUNCION = ( + "# say no se toca\r\n" + 'tellraw @a {"text":"The shop is open"}\r\n' + "scoreboard players add @a visits 1\r\n" + "say Come in\r\n" +) + + +def test_logros_y_funciones(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + datos = juego / "kubejs" / "data" / "fc" / "advancements" / "farm" + funciones = juego / "kubejs" / "data" / "fc" / "functions" + datos.mkdir(parents=True) + funciones.mkdir(parents=True) + (datos / "harvest.json").write_text(json.dumps(LOGRO), encoding="utf-8") + (juego / "kubejs" / "data" / "fc" / "recipes").mkdir() + (juego / "kubejs" / "data" / "fc" / "recipes" / "r.json").write_text(json.dumps(LOGRO), encoding="utf-8") + (funciones / "open.mcfunction").write_bytes(FUNCION.encode()) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + modulo = Datos(ctx) + assert modulo.detectar() and modulo.describir() == "2 archivos de datos" + resumen = traducir_modulo(modulo, Traductor(Simulador(), memoria, Glosario())) + assert resumen.traducidos == 4 and resumen.pendientes == 0 and len(resumen.archivos) == 2 + logro = json.loads((datos / "harvest.json").read_text(encoding="utf-8")) + assert logro["display"]["title"] == {"text": "«ES» First Harvest", "color": "gold"} + assert logro["display"]["description"] == "«ES» Harvest your first crop" + assert logro["display"]["icon"] == LOGRO["display"]["icon"] and logro["criteria"] == LOGRO["criteria"] + nueva = (funciones / "open.mcfunction").read_bytes().decode() + assert nueva == ( + "# say no se toca\r\n" + 'tellraw @a {"text": "«ES» The shop is open"}\r\n' + "scoreboard players add @a visits 1\r\n" + "say «ES» Come in\r\n" + ) + assert json.loads((juego / "kubejs" / "data" / "fc" / "recipes" / "r.json").read_text("utf-8")) == LOGRO + modulo.restaurar() + assert json.loads((datos / "harvest.json").read_text(encoding="utf-8")) == LOGRO + assert (funciones / "open.mcfunction").read_bytes().decode() == FUNCION + + +ARMADO = """const efectos = [ + { id: 'minecraft:luck', name: 'Luck' }, + { id: 'minecraft:speed', name: 'Speed' }, +] +const avisar = (player, lista) => { + let mensaje = 'The food has given you ' + for (let i = 0; i < lista.length; i++) { + if (i > 0) mensaje += ', ' + if (i > 0 && i === lista.length - 1) mensaje += 'and ' + mensaje += lista[i].name + mensaje += global.nombres[lista[i]] + } + let otro = 'Not a message at all' + player.tell(mensaje) + if (player.name == 'Steve') player.tell('Hi') +} +""" +TABLA = """global.nombres = { + fcSweet: 'Sweet', + fcSavory: 'Savory', +} +global.ids = { dulce: 'Not shown' } +""" + + +def test_mensajes_armados_por_partes(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + server = juego / "kubejs" / "server_scripts" / "dieta.js" + startup = juego / "kubejs" / "startup_scripts" / "globales.js" + for ruta, src in ((server, ARMADO), (startup, TABLA)): + ruta.parent.mkdir(parents=True, exist_ok=True) + ruta.write_text(src, encoding="utf-8") + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + resumen = traducir_modulo(ScriptsKubeJS(ctx), Traductor(Simulador(), memoria, Glosario())) + nuevo = server.read_text(encoding="utf-8") + assert "let mensaje = '«ES» The food has given you '" in nuevo and "mensaje += '«ES» and '" in nuevo + assert "name: '«ES» Luck'" in nuevo and "name: '«ES» Speed'" in nuevo + assert "id: 'minecraft:luck'" in nuevo and "mensaje += ', '" in nuevo # sin letras: no es texto + assert "let otro = 'Not a message at all'" in nuevo # no llega a ningún mensaje + assert "tell('«ES» Hi')" in nuevo and "'Steve'" in nuevo + tabla = startup.read_text(encoding="utf-8") + assert "fcSweet: '«ES» Sweet'" in tabla and "fcSavory: '«ES» Savory'" in tabla + assert "dulce: 'Not shown'" in tabla # esa tabla no se suma a ningún mensaje + assert resumen.pendientes == 0 + ScriptsKubeJS(ctx).restaurar() + assert server.read_text(encoding="utf-8") == ARMADO and startup.read_text(encoding="utf-8") == TABLA diff --git a/tests/test_corpus_datos.py b/tests/test_corpus_datos.py new file mode 100644 index 0000000..786575e --- /dev/null +++ b/tests/test_corpus_datos.py @@ -0,0 +1,144 @@ +"""Datos de texto reales (modpacks públicos de .descargas y tu FC4): siempre sobre copias en una carpeta temporal. + +Para cada modpack: lo traducido sigue siendo válido (JSON, zips, JS), repetir no cambia nada y restaurar devuelve +los bytes originales. Los modpacks se bajan con `uv run python scripts/descargar_corpus.py`. +""" + +from __future__ import annotations + +import io +import json +import os +import shutil +import subprocess +import zipfile +from collections.abc import Iterator +from pathlib import Path +from typing import Any, cast + +import pytest + +from traductor_es.fuentes.instancia import Instancia +from traductor_es.modulos.base import Contexto, traducir_modulo +from traductor_es.modulos.cobertura import revisar_cobertura +from traductor_es.modulos.datos import Datos, buscar_archivos, buscar_zips +from traductor_es.modulos.scripts import ScriptsKubeJS +from traductor_es.motores.simulador import Simulador +from traductor_es.nucleo.glosario import Glosario +from traductor_es.nucleo.memoria import Memoria +from traductor_es.nucleo.traductor import Traductor +from traductor_es.salida.copias import Respaldo + +from . import fabrica + +pytestmark = pytest.mark.corpus + +RAIZ = Path(__file__).resolve().parent.parent +DESCARGAS = RAIZ / ".descargas" +FC4 = ( + Path(os.environ.get("APPDATA", Path.home())) / "PrismLauncher" / "instances" / "Farming Crossing 4(1)" / "minecraft" +) +#: Lo que se copia de cada modpack: donde viven los datos de texto y los scripts. +CARPETAS = ( + "kubejs", + "global_packs", + "datapacks", + "config/openloader", + "config/paxi", + "resourcepacks", + "resources", + "saves", +) +LOADERS = {"forge": "forge", "neoforge": "neoforge", "fabric-loader": "fabric", "quilt-loader": "quilt"} + + +def _packs() -> list[str]: + return sorted(p.parent.name for p in DESCARGAS.glob("*/resumen.json")) + + +def _version_y_loader(slug: str) -> tuple[str, str | None]: + resumen = cast("dict[str, Any]", json.loads((DESCARGAS / slug / "resumen.json").read_text(encoding="utf-8"))) + deps = cast("dict[str, str]", resumen["dependencias"]) + return deps["minecraft"], next((v for k, v in LOADERS.items() if k in deps), None) + + +def _copiar(origen: Path, juego: Path) -> None: + for c in CARPETAS: + if (origen / c).is_dir(): + shutil.copytree(origen / c, juego / c, dirs_exist_ok=True) + + +def _todos(juego: Path) -> dict[str, bytes]: + return {p.relative_to(juego).as_posix(): p.read_bytes() for p in juego.rglob("*") if p.is_file()} + + +def _node() -> str | None: + return shutil.which("node") + + +def _js_valido(ruta: Path) -> bool: + node = _node() + assert node + return subprocess.run([node, "--check", str(ruta)], capture_output=True, check=False).returncode == 0 + + +@pytest.fixture +def memoria() -> Iterator[Memoria]: + with Memoria(":memory:") as m: + yield m + + +def _verificar(juego: Path, antes: dict[str, bytes]) -> int: + """Lo cambiado sigue siendo válido. Devuelve cuántos archivos cambiaron.""" + cambiados = 0 + for rel, datos in _todos(juego).items(): + if rel.startswith("traductor_es/") or antes.get(rel) == datos: + continue + cambiados += 1 + if rel.endswith(".json"): + json.loads(datos.decode("utf-8-sig")) + elif rel.endswith(".mcfunction"): + assert datos.count(b"\n") == antes[rel].count(b"\n"), rel # ni se pierden ni se parten líneas + elif rel.endswith(".zip"): + with zipfile.ZipFile(io.BytesIO(datos)) as z, zipfile.ZipFile(io.BytesIO(antes[rel])) as o: + assert z.testzip() is None and z.namelist() == o.namelist(), rel + elif rel.endswith(".js") and _node() and _js_valido(juego / rel) is False: + ruta_antes = juego / rel + ruta_antes.write_bytes(antes[rel]) # ¿ya era inválido antes? + assert not _js_valido(ruta_antes), f"{rel} era JavaScript válido y la traducción lo rompió" + ruta_antes.write_bytes(datos) + return cambiados + + +def _probar(origen: Path, version: str, loader: str | None, tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, version, loader) + _copiar(origen, juego) + antes = _todos(juego) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + if not (buscar_archivos(juego) or buscar_zips(juego) or ScriptsKubeJS(ctx).detectar()): + pytest.skip("Este modpack no trae datos de texto ni scripts") + traductor = Traductor(Simulador(), memoria, Glosario()) + for modulo in (Datos(ctx), ScriptsKubeJS(ctx)): + traducir_modulo(modulo, traductor) + assert _verificar(juego, antes) >= 0 + despues = _todos(juego) + for modulo in (Datos(ctx), ScriptsKubeJS(ctx)): + traducir_modulo(modulo, traductor) + assert _todos(juego) == despues # repetir no cambia nada + revisar_cobertura(ctx) # no debe fallar con ningún modpack + Datos(ctx).restaurar() + ScriptsKubeJS(ctx).restaurar() + restaurado = {k: v for k, v in _todos(juego).items() if not k.startswith("traductor_es/")} + assert restaurado == {k: v for k, v in antes.items() if not k.startswith("traductor_es/")} + + +@pytest.mark.parametrize("slug", _packs()) +def test_datos_de_modpacks_descargados(slug: str, tmp_path: Path, memoria: Memoria) -> None: + version, loader = _version_y_loader(slug) + _probar(DESCARGAS / slug, version, loader, tmp_path, memoria) + + +@pytest.mark.skipif(not (FC4 / "kubejs").is_dir(), reason="Falta FC4") +def test_datos_de_fc4(tmp_path: Path, memoria: Memoria) -> None: + _probar(FC4, "1.20.1", "forge", tmp_path, memoria) diff --git a/tests/test_datos_generales.py b/tests/test_datos_generales.py new file mode 100644 index 0000000..12b29bb --- /dev/null +++ b/tests/test_datos_generales.py @@ -0,0 +1,203 @@ +"""Datos de texto en cualquier sitio y versión: se descubren por su estructura, no por la carpeta de un modpack.""" + +import json +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from traductor_es.formatos import componente +from traductor_es.fuentes.instancia import Instancia +from traductor_es.modulos.base import Contexto, traducir_modulo +from traductor_es.modulos.datos import Datos, buscar_archivos, clasificar +from traductor_es.motores.simulador import Simulador +from traductor_es.nucleo.glosario import Glosario +from traductor_es.nucleo.memoria import Memoria +from traductor_es.nucleo.traductor import Traductor +from traductor_es.salida.copias import Respaldo + +from . import fabrica + + +@pytest.fixture +def memoria() -> Iterator[Memoria]: + with Memoria(":memory:") as m: + yield m + + +@pytest.mark.parametrize( + ("cuerpo", "esperado"), + [ + ('{"text":"Hi","color":"red"}', ["Hi"]), + ('["",{"text":"Hi "},{"text":"there"}]', ["Hi ", "there"]), + ('"Hi"', ["Hi"]), + ('{text:"Hi",color:"red"}', ["Hi"]), # SNBT, desde 1.21.5 + ('["",{text:"Aa"},{text:"Bb",extra:["Cc"]}]', ["Aa", "Bb", "Cc"]), + ('{translate:"chat.type.text",with:["Steve"],color:"red"}', []), + ('{"score":{"name":"@p","objective":"x"}}', []), + ("esto no es un componente", None), + ], +) +def test_componente_json_y_snbt(cuerpo: str, esperado: list[str] | None) -> None: + assert componente.unidades(cuerpo) == esperado + + +def test_componente_snbt_conserva_el_formato() -> None: + cuerpo = '{text:"Hi", color:"red", hover_event:{action:"show_text", value:"Hola"}}' + nuevo = componente.reconstruir(cuerpo, {"Hi": "Hola, amigo", "Hola": "no debe usarse"}) + assert nuevo == '{text:"Hola, amigo", color:"red", hover_event:{action:"show_text", value:"Hola"}}' + + +@pytest.mark.parametrize( + ("ruta", "formato"), + [ + ("kubejs/data/a/functions/x.mcfunction", "función"), + ("global_packs/required_data/p/data/a/function/sub/x.mcfunction", "función"), # 1.21: «function» + ("datapacks/p/data/a/advancement/x.json", "logro"), + ("saves/w/datapacks/p/data/a/advancements/b/x.json", "logro"), + ("kubejs/assets/a/tips/t.json", "tip"), + ("resourcepacks/p/assets/a/tips/t.json", "tip"), + ("kubejs/data/tipsmod/tips/t.json", "tip"), + ("kubejs/data/a/recipes/x.json", None), + ("kubejs/data/a/functions/x.txt", None), + ("config/functions/x.mcfunction", None), # sin «data»: no es un datapack + ("assets/a/lang/en_us.json", None), + ], +) +def test_clasificar_por_estructura(ruta: str, formato: str | None) -> None: + resultado = clasificar(Path(ruta)) + assert (resultado.nombre if resultado else None) == formato + + +def _escribir(base: Path, ruta: str, texto: str) -> Path: + p = base / ruta + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(texto, encoding="utf-8", newline="") + return p + + +def test_descubre_en_cualquier_carpeta_e_ignora_lo_que_no_toca(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + fn = 'tellraw @a {"text":"Hi"}\n' + buenos = [ + "kubejs/data/a/functions/x.mcfunction", + "global_packs/required_data/p/data/a/functions/x.mcfunction", + "datapacks/p/data/a/functions/x.mcfunction", + "saves/Mundo/datapacks/p/data/a/functions/x.mcfunction", + "config/openloader/data/p/data/a/functions/x.mcfunction", + ] + malos = [ + "mods/m/data/a/functions/x.mcfunction", # los jars no se tocan + "saves/Mundo/region/data/a/functions/x.mcfunction", # de un mundo solo importan sus datapacks + "resourcepacks/Traduccion_ES/data/a/functions/x.mcfunction", # lo que escribe la app + "traductor_es/respaldo/kubejs/data/a/functions/x.mcfunction", # las copias de seguridad + "logs/data/a/functions/x.mcfunction", + ] + for r in (*buenos, *malos): + _escribir(juego, r, fn) + encontrados = {p.relative_to(juego).as_posix() for p, _ in buscar_archivos(juego)} + assert encontrados == set(buenos) + + +def test_traduce_logros_snbt_funciones_y_tips(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.21.5") + fn = _escribir( + juego, "datapacks/p/data/a/function/x.mcfunction", 'tellraw @a {text:"Hello",color:"gold"}\nsay Bye\n' + ) + logro = _escribir( + juego, + "kubejs/data/a/advancement/x.json", + json.dumps({"display": {"title": "Big Win", "description": {"translate": "k.d"}}, "criteria": {}}), + ) + tip1 = _escribir(juego, "kubejs/assets/a/tips/t1.json", json.dumps({"tip": {"text": "Eat your greens."}})) + tip2 = _escribir( + juego, "kubejs/data/tipsmod/tips/t2.json", json.dumps({"tips": [{"text": "Sleep in beds.", "category": "x"}]}) + ) + negativo = _escribir(juego, "kubejs/data/a/recipes/r.json", json.dumps({"result": {"item": "Big Win"}})) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + modulo = Datos(ctx) + assert modulo.describir() == "4 archivos de datos" + resumen = traducir_modulo(modulo, Traductor(Simulador(), memoria, Glosario())) + assert resumen.pendientes == 0 and len(resumen.archivos) == 4 + assert fn.read_text("utf-8") == 'tellraw @a {text:"«ES» Hello",color:"gold"}\nsay «ES» Bye\n' + assert json.loads(logro.read_text("utf-8"))["display"] == { + "title": "«ES» Big Win", + "description": {"translate": "k.d"}, + } + assert json.loads(tip1.read_text("utf-8")) == {"tip": {"text": "«ES» Eat your greens."}} + assert json.loads(tip2.read_text("utf-8"))["tips"] == [{"text": "«ES» Sleep in beds.", "category": "x"}] + assert "«ES»" not in negativo.read_text("utf-8") + # Repetir no cambia nada, y restaurar devuelve los originales + antes = {p: p.read_bytes() for p in (fn, logro, tip1, tip2)} + traducir_modulo(Datos(ctx), Traductor(Simulador(), memoria, Glosario())) + assert {p: p.read_bytes() for p in antes} == antes + assert len(Datos(ctx).restaurar()) == 4 + assert fn.read_text("utf-8") == 'tellraw @a {text:"Hello",color:"gold"}\nsay Bye\n' + assert json.loads(tip1.read_text("utf-8")) == {"tip": {"text": "Eat your greens."}} + + +def test_archivo_ilegible_se_deja_igual(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + roto = _escribir(juego, "kubejs/data/a/advancements/roto.json", '{"display": {"title": "Hi",}') + avisos: list[str] = [] + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego), registro=avisos.append) + traducir_modulo(Datos(ctx), Traductor(Simulador(), memoria, Glosario())) + assert roto.read_text("utf-8") == '{"display": {"title": "Hi",}' and any("roto.json" in a for a in avisos) + + +def test_datapack_comprimido_se_traduce_y_se_restaura(tmp_path: Path, memoria: Memoria) -> None: + import io + import zipfile + + juego = fabrica.prism(tmp_path, "1.20.1") + ruta = juego / "global_packs" / "required_data" / "mi pack v1.zip" + ruta.parent.mkdir(parents=True) + with zipfile.ZipFile(ruta, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("pack.mcmeta", '{"pack": {"pack_format": 15, "description": "Hello pack"}}') + z.writestr("data/a/functions/x.mcfunction", 'tellraw @a {"text":"Hello there"}\n') + z.writestr("data/a/recipes/r.json", '{"result": "Hello there"}') + z.writestr("data/a/advancements/g.json", json.dumps({"display": {"title": "Big Win"}})) + original = ruta.read_bytes() + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + modulo = Datos(ctx) + assert modulo.describir() == "2 archivos de datos" + resumen = traducir_modulo(modulo, Traductor(Simulador(), memoria, Glosario())) + assert resumen.traducidos == 2 and resumen.archivos == [ruta] + with zipfile.ZipFile(io.BytesIO(ruta.read_bytes())) as z: + assert z.testzip() is None + assert z.namelist() == [ + "pack.mcmeta", + "data/a/functions/x.mcfunction", + "data/a/recipes/r.json", + "data/a/advancements/g.json", + ] + assert "«ES» Hello there" in z.read("data/a/functions/x.mcfunction").decode() + assert json.loads(z.read("data/a/advancements/g.json"))["display"]["title"] == "«ES» Big Win" + assert "«ES»" not in z.read("data/a/recipes/r.json").decode() + z.read("pack.mcmeta").decode() + despues = ruta.read_bytes() + traducir_modulo(Datos(ctx), Traductor(Simulador(), memoria, Glosario())) + assert ruta.read_bytes() == despues # repetir no cambia nada + assert Datos(ctx).restaurar() == [ruta] + assert ruta.read_bytes() == original + + +def test_mercado_de_farming_for_blockheads(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + ruta = juego / "kubejs" / "data" / "farmingforblockheads" / "farmingforblockheads_compat" / "kits.json" + contenido = { + "customCategories": {"kits": {"name": "Clothes and Decoration Kits", "icon": {"item": "minecraft:stone"}}}, + "customEntries": [{"output": {"item": "x:y", "nbt": {"Name": "Item name stays"}}}], + } + ruta.parent.mkdir(parents=True) + ruta.write_text(json.dumps(contenido), encoding="utf-8") + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + traducir_modulo(Datos(ctx), Traductor(Simulador(), memoria, Glosario())) + nuevo = json.loads(ruta.read_text("utf-8")) + assert nuevo["customCategories"]["kits"]["name"] == "«ES» Clothes and Decoration Kits" + assert nuevo["customEntries"] == contenido["customEntries"] and nuevo["customCategories"]["kits"]["icon"] + Datos(ctx).restaurar() + assert json.loads(ruta.read_text("utf-8")) == contenido diff --git a/tests/test_funciones_de_texto.py b/tests/test_funciones_de_texto.py new file mode 100644 index 0000000..644f986 --- /dev/null +++ b/tests/test_funciones_de_texto.py @@ -0,0 +1,122 @@ +"""Funciones del propio script que solo muestran texto: se traduce lo que se les pasa, y nada que pueda ser lógica.""" + +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from traductor_es.fuentes import vanilla +from traductor_es.fuentes.instancia import Instancia +from traductor_es.modulos.base import Contexto, traducir_modulo +from traductor_es.modulos.scripts import ScriptsKubeJS +from traductor_es.motores.simulador import Simulador +from traductor_es.nucleo.glosario import Glosario +from traductor_es.nucleo.memoria import Memoria +from traductor_es.nucleo.traductor import Traductor +from traductor_es.salida.copias import Respaldo + +from . import fabrica + + +@pytest.fixture(autouse=True) +def _sin_lanzadores(monkeypatch: pytest.MonkeyPatch) -> None: + def ninguno() -> list[Path]: + return [] + + monkeypatch.setattr(vanilla, "raices_de_lanzadores", ninguno) + + +@pytest.fixture +def memoria() -> Iterator[Memoria]: + with Memoria(":memory:") as m: + yield m + + +def _traducir(tmp_path: Path, memoria: Memoria, src: str) -> tuple[str, int]: + juego = fabrica.prism(tmp_path, "1.20.1") + ruta = juego / "kubejs" / "server_scripts" / "s.js" + ruta.parent.mkdir(parents=True) + ruta.write_text(src, encoding="utf-8") + with Instancia(juego) as inst: + resumen = traducir_modulo( + ScriptsKubeJS(Contexto(inst, Respaldo(juego))), Traductor(Simulador(), memoria, Glosario()) + ) + return ruta.read_text("utf-8"), resumen.traducidos + + +# Parámetros que solo se muestran, directamente o después de partirlos en líneas (como el tooltip de Create de un modpack real) +TEXTO_PURO = r""" +function aviso(player, msg) { player.tell(msg) } +const consejo = (evento, objeto, texto) => { + texto = texto.trim() + const lineas = texto.split('\n') + lineas.forEach(linea => { + evento.add(objeto, Text.of(linea).gold()) + }) +} +const decir = (p, quien, frase) => { p.tell(`${quien}: ${frase}`) } +aviso(player, 'Welcome back to the farm') +consejo(e, 'minecraft:stone', 'Hold shift to see the summary\nIt can be mined with a pickaxe') +decir(player, 'Laly', 'The museum opens at dawn') +""" + + +def test_funciones_que_solo_muestran_texto(tmp_path: Path, memoria: Memoria) -> None: + nuevo, n = _traducir(tmp_path, memoria, TEXTO_PURO) + assert "aviso(player, '«ES» Welcome back to the farm')" in nuevo + assert "'«ES» Hold shift to see the summary\\nIt can be mined with a pickaxe'" in nuevo + assert "'«ES» The museum opens at dawn'" in nuevo # `quien` y `frase` se muestran juntos en la plantilla + assert "'minecraft:stone'" in nuevo and n >= 3 + + +# Cada función usa su parámetro como lógica de algún modo: ninguna debe tocarse +PELIGROSAS = r""" +function comparar(p, texto) { if (texto == 'Open the door now') p.tell(texto) } +function buscar(p, texto) { if (texto.includes('door')) p.tell(texto) } +function guardar(p, texto) { p.persistentData.put(texto, 1); p.tell(texto) } +function devolver(texto) { return texto } +function mostrar_y_clave(p, texto) { p.tell(texto); p.data[texto] = true } +function por_plantilla(p, texto) { p.runCommand(`give @s ${texto}`) ; p.tell(texto) } +function como_valor(p, texto) { p.tell(texto) } +const alias = como_valor +function repetida(p, texto) { p.tell(texto) } +function repetida(p, texto) { p.tell(texto) } +comparar(player, 'Open the door now') +buscar(player, 'Look at the door here') +guardar(player, 'Some key with spaces') +devolver('Return this phrase please') +mostrar_y_clave(player, 'Another key with spaces') +por_plantilla(player, 'Some item with spaces') +como_valor(player, 'Called through a value') +repetida(player, 'Defined twice in the file') +""" + + +def test_si_el_parametro_es_logica_no_se_toca_nada(tmp_path: Path, memoria: Memoria) -> None: + nuevo, n = _traducir(tmp_path, memoria, PELIGROSAS) + assert nuevo == PELIGROSAS and n == 0 + + +def test_un_metodo_con_el_mismo_nombre_no_es_la_funcion_del_script(tmp_path: Path, memoria: Memoria) -> None: + src = "function aviso(p, msg) { p.tell(msg) }\ninventario.aviso(player, 'This is another object method')\n" + nuevo, n = _traducir(tmp_path, memoria, src) + assert nuevo == src and n == 0 + + +def test_solo_la_posicion_del_parametro_y_nunca_ids(tmp_path: Path, memoria: Memoria) -> None: + src = ( + "function aviso(p, msg, id) { p.tell(msg) }\naviso(player, 'Something to show here', 'quest id with spaces')\n" + ) + nuevo, _ = _traducir(tmp_path, memoria, src) + assert "'«ES» Something to show here'" in nuevo and "'quest id with spaces'" in nuevo + + +def test_el_id_que_va_en_una_plantilla_no_se_toca_pero_el_nombre_si(tmp_path: Path, memoria: Memoria) -> None: + src = ( + "function crear(id, nombre) {\n" + " event.create(id).displayName(nombre).model(`kubejs:block/${id}`)\n" + "}\n" + "crear('plushie with spaces', 'Taidum Plushie')\n" + ) + nuevo, _ = _traducir(tmp_path, memoria, src) + assert "'plushie with spaces'" in nuevo and "'«ES» Taidum Plushie'" in nuevo diff --git a/tests/test_kubejs_y_mods.py b/tests/test_kubejs_y_mods.py index 90bdbf6..c15c455 100644 --- a/tests/test_kubejs_y_mods.py +++ b/tests/test_kubejs_y_mods.py @@ -196,3 +196,12 @@ def sin_lanzadores() -> list[Path]: with Instancia(juego) as inst: traducir_modulo(Mods(Contexto(inst, Respaldo(juego))), Traductor(Simulador(), memoria, Glosario())) assert not (juego / "resourcepacks" / "Traduccion_ES").exists() + + +def test_metodos_de_texto_de_kubejs_por_version() -> None: + from traductor_es.formatos.kubejs_api import llamadas_de_texto + + assert {"tell", "setStatusMessage", "kick"} <= llamadas_de_texto((1, 12, 2)) + assert "notify" not in llamadas_de_texto((1, 16, 5)) and "notify" in llamadas_de_texto((1, 19, 2)) + assert "openChestGUI" not in llamadas_de_texto((1, 19, 2)) and "openChestGUI" in llamadas_de_texto((1, 20, 1)) + assert "openChestGUI" in llamadas_de_texto((26, 1, 0)) and "openChestGUI" in llamadas_de_texto(None) diff --git a/tests/test_mod_apoyo_coherencia.py b/tests/test_mod_apoyo_coherencia.py new file mode 100644 index 0000000..5598932 --- /dev/null +++ b/tests/test_mod_apoyo_coherencia.py @@ -0,0 +1,158 @@ +"""La tabla de versiones de la app, los proyectos de `mod-runtime/` y los rangos que declaran tienen que coincidir. + +Si se añade una versión a `SOPORTADOS` sin que su proyecto la acepte, el jugador instalaría un mod que el juego +rechaza al abrirse; y un proyecto que ninguna entrada usa es un mod que nadie recibe. Esto lo vigila sin necesidad de +compilar nada. +""" + +from __future__ import annotations + +import importlib.util +import re +from pathlib import Path +from types import ModuleType + +import pytest + +from traductor_es.fuentes.version import Version +from traductor_es.salida.mod_apoyo import IMPOSIBLES, SOPORTADOS + +RAIZ = Path(__file__).resolve().parent.parent +PROYECTOS = RAIZ / "mod-runtime" +#: Quilt carga el jar de Fabric, y NeoForge 1.20.1 (un fork de Forge) el de Forge 1.20.1. +FAMILIA_DEL_JAR = {"forge": "forge", "neoforge": "neoforge", "fabric": "fabric", "quilt": "fabric"} + + +def _propiedades(proyecto: str) -> dict[str, str]: + texto = (PROYECTOS / proyecto / "gradle.properties").read_text(encoding="utf-8") + pares = (linea.split("=", 1) for linea in texto.splitlines() if "=" in linea and not linea.startswith("#")) + return {k.strip(): v.strip() for k, v in pares} + + +def _rango(proyecto: str) -> str: + """El rango de Minecraft que declara el proyecto, tal cual (Fabric: `>=a <=b`; Forge y NeoForge: `[a,b)`).""" + p = _propiedades(proyecto) + return p.get("minecraft_range") or p.get("minecraft_version_range") or p["minecraft_version"] + + +def _acepta(rango: str, version: Version) -> bool: + rango = rango.strip() + if rango.startswith(("[", "(")): # intervalo de Maven: [1.21,1.21.4) · [1.21.11] + abre, cierra = rango[0], rango[-1] + extremos = [e.strip() for e in rango[1:-1].split(",")] + if len(extremos) == 1: + return version == Version.de(extremos[0]) + inferior = Version.de(extremos[0]) + ok = version >= inferior if abre == "[" else version > inferior + if extremos[1]: + superior = Version.de(extremos[1]) + ok = ok and (version <= superior if cierra == "]" else version < superior) + return ok + if rango.startswith((">=", "<=", ">", "<")): # rango de Fabric: >=1.20 <=1.20.2 + for condicion in rango.split(): + m = re.fullmatch(r"(>=|<=|>|<)(.+)", condicion) + assert m, condicion + limite = Version.de(m.group(2)) + if not {">=": version >= limite, "<=": version <= limite, ">": version > limite, "<": version < limite}[ + m.group(1) + ]: + return False + return True + return version == Version.de(rango) # una sola versión + + +def _proyectos_de_mod_runtime() -> list[str]: + return sorted(p.name for p in PROYECTOS.iterdir() if (p / "build.gradle").is_file()) + + +def _construir_mod() -> ModuleType: + spec = importlib.util.spec_from_file_location("construir_mod", RAIZ / "scripts" / "construir_mod.py") + assert spec and spec.loader + modulo = importlib.util.module_from_spec(spec) + spec.loader.exec_module(modulo) + return modulo + + +def test_cada_destino_de_la_tabla_es_un_proyecto_que_se_construye() -> None: + destinos = set(SOPORTADOS.values()) + proyectos = set(_proyectos_de_mod_runtime()) + assert destinos <= proyectos, f"destinos sin proyecto en mod-runtime/: {sorted(destinos - proyectos)}" + construibles = set(_construir_mod().PROYECTOS) + assert construibles == proyectos, ( + f"proyectos que construir_mod.py no conoce o que ya no existen: {sorted(construibles ^ proyectos)}" + ) + + +def test_ningun_proyecto_queda_sin_usar() -> None: + sin_usar = set(_proyectos_de_mod_runtime()) - set(SOPORTADOS.values()) + assert not sin_usar, f"proyectos que ninguna versión de la tabla recibe: {sorted(sin_usar)}" + + +@pytest.mark.parametrize(("clave", "destino"), sorted(SOPORTADOS.items())) +def test_la_version_cae_en_el_rango_de_su_proyecto(clave: tuple[str, tuple[int, ...]], destino: str) -> None: + loader, partes = clave + version = Version.de(".".join(str(n) for n in partes)) + rango = _rango(destino) + assert _acepta(rango, version), f"{loader} {version} usa {destino}, que solo acepta {rango}" + familia_del_jar = destino.split("-", maxsplit=1)[0] + esperado = {FAMILIA_DEL_JAR[loader]} + if (loader, partes) == ("neoforge", (1, 20, 1)): + esperado = {"forge"} # NeoForge 1.20.1 carga los mods de Forge 1.20.1 + assert familia_del_jar in esperado, f"{loader} {version} no puede usar el jar {destino}" + + +def test_lo_imposible_no_esta_tambien_soportado() -> None: + assert not set(IMPOSIBLES) & set(SOPORTADOS) + + +@pytest.mark.parametrize( + ("rango", "version", "esperado"), + [ + (">=1.20 <=1.20.2", "1.20.1", True), + (">=1.20 <=1.20.2", "1.20.3", False), + ("[1.21,1.21.4)", "1.21.3", True), + ("[1.21,1.21.4)", "1.21.4", False), + ("[1.21.11]", "1.21.11", True), + ("[1.21.11]", "1.21.10", False), + ("1.19.4", "1.19.4", True), + ("1.19.4", "1.19.3", False), + ], +) +def test_el_lector_de_rangos(rango: str, version: str, esperado: bool) -> None: + assert _acepta(rango, Version.de(version)) is esperado + + +# ------------------------------------------------------------------ reintentos de red al compilar +def _comando(tmp_path: Path, mensaje: str, falla_la_primera_vez: bool) -> list[str]: + """Un «Gradle» de mentira: escribe `mensaje` y falla (siempre, o solo la primera vez).""" + import sys + + guion = tmp_path / "falso.py" + guion.write_text( + "import pathlib, sys\n" + f"marca = pathlib.Path({str(tmp_path / 'marca')!r})\n" + f"print({mensaje!r})\n" + f"if {falla_la_primera_vez!r} and marca.exists():\n sys.exit(0)\n" + "marca.write_text('x')\nsys.exit(1)\n", + encoding="utf-8", + ) + return [sys.executable, str(guion)] + + +def test_un_fallo_de_red_se_reintenta(tmp_path: Path) -> None: + construir = _construir_mod() + orden = _comando(tmp_path, "java.net.SocketTimeoutException: Read timed out", falla_la_primera_vez=True) + assert construir._ejecutar(orden, tmp_path, dict(__import__("os").environ), espera=0) == 0 + + +def test_un_error_del_codigo_no_se_reintenta(tmp_path: Path) -> None: + construir = _construir_mod() + orden = _comando(tmp_path, "error: cannot find symbol", falla_la_primera_vez=True) + assert construir._ejecutar(orden, tmp_path, dict(__import__("os").environ), espera=0) == 1 + assert (tmp_path / "marca").exists() # corrió una sola vez: no hubo segundo intento que lo hiciera pasar + + +def test_un_fallo_de_red_que_no_se_arregla_termina_tras_tres_intentos(tmp_path: Path) -> None: + construir = _construir_mod() + orden = _comando(tmp_path, "Server returned HTTP response code: 504", falla_la_primera_vez=False) + assert construir._ejecutar(orden, tmp_path, dict(__import__("os").environ), espera=0) == 1 diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..3cb3276 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,297 @@ +"""Texto del código de los mods: la tabla para el mod de apoyo y su instalación en mods/.""" + +import json +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from traductor_es.__main__ import main +from traductor_es.fuentes import vanilla +from traductor_es.fuentes.instancia import Instancia +from traductor_es.modulos.base import Contexto, traducir_modulo +from traductor_es.modulos.runtime import RUTA_TABLA, TextoDeJava +from traductor_es.motores.simulador import Simulador +from traductor_es.nucleo.dialectos import VARIANTES +from traductor_es.nucleo.glosario import Glosario +from traductor_es.nucleo.mascara import enmascarar +from traductor_es.nucleo.memoria import Memoria +from traductor_es.nucleo.traductor import Traductor +from traductor_es.proceso import Proceso +from traductor_es.salida import mod_apoyo +from traductor_es.salida.copias import Respaldo +from traductor_es.salida.resource_pack import PackSalida + +from . import fabrica +from .test_cobertura import _clase # pyright: ignore[reportPrivateUsage] + +FRASES = { + "A": "You can't use chat, you've been muted by an admin!", + "B": "Click §ahere§r to open the menu %s now", + "C": "§cThe market isn't open today, come back later!", +} + + +@pytest.fixture(autouse=True) +def _sin_lanzadores(monkeypatch: pytest.MonkeyPatch) -> None: + def ninguno() -> list[Path]: + return [] + + monkeypatch.setattr(vanilla, "raices_de_lanzadores", ninguno) + + +@pytest.fixture +def memoria() -> Iterator[Memoria]: + with Memoria(":memory:") as m: + yield m + + +def _instancia_con_mod(tmp_path: Path, version: str = "1.20.1", loader: str | None = "forge") -> Path: + juego = fabrica.prism(tmp_path, version, loader) + fabrica.jar( + juego / "mods" / "m.jar", + { + "com/m/Chat.class": _clase("literal", *FRASES.values()), + "com/m/Otra.class": _clase("nada", "Another English sentence here"), # no construye texto + }, + ) + return juego + + +def test_tabla_con_las_frases_del_codigo(tmp_path: Path, memoria: Memoria) -> None: + juego = _instancia_con_mod(tmp_path) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego), pack=PackSalida(inst)) + modulo = TextoDeJava(ctx) + resumen = traducir_modulo(modulo, Traductor(Simulador(), memoria, Glosario())) + pack = ctx.pack_listo().raiz + assert resumen.traducidos == 3 and len(resumen.archivos) == len(VARIANTES) + for variante in VARIANTES: + crudo = (pack / RUTA_TABLA.format(variante=variante)).read_bytes() + tabla = json.loads(crudo.decode("utf-8")) + assert set(tabla) == set(FRASES.values()) # la clave es el inglés exacto + for ingles, espanol in tabla.items(): + assert espanol.startswith("«ES» ") + assert sorted(enmascarar(espanol).tokens) == sorted(enmascarar(ingles).tokens) # §a, §r y %s intactos + assert "Another English sentence here" not in json.dumps(tabla) + + +def test_sin_frases_no_crea_nada(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + fabrica.jar(juego / "mods" / "m.jar", fabrica.lang("m", "en_us", {"item.m.a": "Iron Plate"})) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego), pack=PackSalida(inst)) + resumen = traducir_modulo(TextoDeJava(ctx), Traductor(Simulador(), memoria, Glosario())) + assert resumen.archivos == [] and not (juego / "resourcepacks" / "Traduccion_ES" / "assets").exists() + + +def test_proceso_solo_incluye_el_modulo_si_se_pide(tmp_path: Path, memoria: Memoria) -> None: + juego = _instancia_con_mod(tmp_path) + with Proceso(juego, Simulador(), memoria=memoria, grupos=["mods"]) as p: + assert not any(isinstance(m, TextoDeJava) for _, m in p.modulos()) + sin = p.analizar() + with Proceso(juego, Simulador(), memoria=memoria, grupos=["mods"], texto_java=True) as p: + assert any(isinstance(m, TextoDeJava) for _, m in p.modulos()) + con = p.analizar() + p.ejecutar(activar=False, poner_idioma=False) + assert con.a_traducir == sin.a_traducir + 3 + assert (juego / "resourcepacks" / "Traduccion_ES" / RUTA_TABLA.format(variante="es_mx")).is_file() + + +# ------------------------------------------------------------------ mod de apoyo +def _jars(tmp_path: Path, *nombres: str) -> Path: + carpeta = tmp_path / "empaquetados" + carpeta.mkdir() + for n in nombres: + (carpeta / n).write_bytes(b"PK-" + n.encode()) + return carpeta + + +def test_mod_de_apoyo_solo_para_lo_soportado(tmp_path: Path) -> None: + casos = [ + ("1.19.2", "forge", "forge-1.19.2"), + ("1.20.1", "forge", "forge-1.20.1"), + ("1.21.1", "neoforge", "neoforge-1.21.1"), + ("1.19.4", "fabric", "fabric-1.19.4"), + ("1.20.1", "fabric", "fabric-1.20.1"), + ("1.21.1", "fabric", "fabric-1.21.1"), + ("1.21.4", "fabric", "fabric-1.21.4"), + ("1.20", "fabric", "fabric-1.20.1"), # un jar sirve para varias versiones vecinas + ("1.20.2", "fabric", "fabric-1.20.1"), + ("1.21", "fabric", "fabric-1.21.1"), + ("1.21.2", "fabric", "fabric-1.21.4"), + ("1.21.3", "fabric", "fabric-1.21.4"), + ("1.21.5", "fabric", "fabric-1.21.5"), + ("1.21.8", "fabric", "fabric-1.21.5"), + ("1.21.10", "fabric", "fabric-1.21.5"), + ("1.21.11", "fabric", "fabric-1.21.11"), + ("26.1", "fabric", "fabric-26.1"), + ("26.1.2", "fabric", "fabric-26.1"), + ("26.2", "fabric", "fabric-26.1"), + ("26.3", "fabric", "fabric-26.1"), + ("26.4", "fabric", None), # todavía solo tiene snapshots + ("1.20.3", "fabric", "fabric-1.20.4"), + ("1.20.4", "fabric", "fabric-1.20.4"), + ("1.20.5", "fabric", "fabric-1.20.6"), + ("1.20.6", "fabric", "fabric-1.20.6"), + ("1.19.3", "fabric", None), # sin jar probado para esa versión + ("1.20.1", "quilt", "fabric-1.20.1"), # Quilt carga los mods de Fabric + ("1.21.1", "quilt", "fabric-1.21.1"), + ("1.19.4", "quilt", "fabric-1.19.4"), + ("1.20.3", "quilt", None), # no hay Quilted Fabric API para esa versión + ("1.21.4", "quilt", None), + ("1.21.12", "fabric", None), + ("1.19.2", "fabric", None), # Fabric API no tiene evento de chat en esa versión + ("1.18.2", "fabric", None), + ("1.18.2", "forge", "forge-1.18.2"), + ("1.21.4", "neoforge", "neoforge-1.21.4"), + ("1.21", "neoforge", "neoforge-1.21.1"), + ("1.21.3", "neoforge", "neoforge-1.21.1"), + ("1.21.5", "neoforge", "neoforge-1.21.5"), + ("1.21.8", "neoforge", "neoforge-1.21.5"), + ("1.21.10", "neoforge", "neoforge-1.21.5"), + ("1.21.11", "neoforge", "neoforge-1.21.11"), + ("1.20.4", "neoforge", None), + ("26.1", "neoforge", None), + ("1.20.1", "neoforge", "forge-1.20.1"), # NeoForge 1.20.1 carga los mods de Forge + ("1.12.2", "forge", None), + ("1.16.5", "forge", "forge-1.16.5"), + ("1.16.5", "fabric", None), + ("1.17.1", "forge", None), + ("1.19.4", "forge", None), + ] + for i, (version, loader, esperado) in enumerate(casos): + juego = fabrica.prism(tmp_path / str(i), version, loader) + with Instancia(juego) as inst: + assert mod_apoyo.destino_de(inst) == esperado + + +def test_instalar_reemplaza_versiones_y_respeta_otros_mods(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1", "forge") + (juego / "mods" / "otro-mod.jar").write_bytes(b"otro") + (juego / "mods" / "modpackes_runtime-forge-1.20.1-0.0.9.jar").write_bytes(b"viejo") + carpeta = _jars(tmp_path, "modpackes_runtime-forge-1.20.1-0.1.0.jar", "modpackes_runtime-forge-1.20.1-0.0.9.jar") + with Instancia(juego) as inst: + final = mod_apoyo.instalar(inst, carpeta) + assert final.name == "modpackes_runtime-forge-1.20.1-0.1.0.jar" and final.read_bytes().startswith(b"PK-") + assert [p.name for p in mod_apoyo.instalados(inst)] == [final.name] # la versión vieja ya no está + assert (juego / "mods" / "otro-mod.jar").read_bytes() == b"otro" + assert mod_apoyo.quitar(inst) == [final] and mod_apoyo.quitar(inst) == [] + assert (juego / "mods" / "otro-mod.jar").is_file() # nunca toca otro mod + + +def test_instalar_con_errores_claros(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path / "a", "1.17.1", "forge") + with Instancia(juego) as inst, pytest.raises(ValueError, match="Todavía no hay mod de apoyo"): + mod_apoyo.instalar(inst, tmp_path) + for i, version in enumerate(("1.16.5", "1.18.2", "1.19.2")): # se dice por qué no, no solo «todavía» + imposible = fabrica.prism(tmp_path / f"c{i}", version, "fabric") + with Instancia(imposible) as inst, pytest.raises(ValueError, match="no tiene en esa versión un evento"): + mod_apoyo.instalar(inst, tmp_path) + juego2 = fabrica.prism(tmp_path / "b", "1.20.1", "forge") + with Instancia(juego2) as inst, pytest.raises(ValueError, match="no trae el mod de apoyo"): + mod_apoyo.instalar(inst, tmp_path / "no_hay") + + +def test_cli_instalar_runtime_sin_soporte(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + juego = fabrica.prism(tmp_path, "1.20.4", "neoforge") + assert main(["instalar-runtime", str(juego.parent)]) != 0 + assert "Todavía no hay mod de apoyo" in capsys.readouterr().out # los errores van por la salida normal + assert main(["instalar-runtime", str(juego.parent), "--quitar"]) == 0 + assert "No había mod de apoyo" in capsys.readouterr().out + + +def test_plantillas_con_datos_variables_y_orden(tmp_path: Path, memoria: Memoria) -> None: + from traductor_es.motores.base import Lote + + class Reordena(Simulador): + """Traduce bien, salvo una frase donde cambia el orden de los datos.""" + + def traducir_lote(self, lote: Lote) -> dict[str, str]: + salida = super().traducir_lote(lote) + for e in lote.entradas: + if "gave" in e.texto: # «⟦0⟧ gave ⟦1⟧ coins» → los marcadores al revés + salida[e.id] = "⟦1⟧ monedas de ⟦0⟧" + return salida + + juego = fabrica.prism(tmp_path, "1.20.1", "forge") + fabrica.jar( + juego / "mods" / "m.jar", + {"com/m/Chat.class": _clase("literal", "Hello %s, welcome back to the farm!", "%s gave %d coins to the shop")}, + ) + avisos: list[str] = [] + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego), pack=PackSalida(inst), registro=avisos.append) + modulo = TextoDeJava(ctx) + piezas = modulo.recolectar() + assert all("marcadores" in p.pista for p in piezas) # el motor sabe que debe respetar el orden + resumen = traducir_modulo(modulo, Traductor(Reordena(), memoria, Glosario())) + pack = ctx.pack_listo().raiz + tabla = json.loads((pack / RUTA_TABLA.format(variante="es_mx")).read_text("utf-8")) + assert tabla == {"Hello %s, welcome back to the farm!": "«ES» Hello %s, welcome back to the farm!"} + assert resumen.traducidos == 1 and resumen.pendientes == 1 and any("cambió el orden" in a for a in avisos) + + +def test_estado_del_mod_de_apoyo(tmp_path: Path) -> None: + sin_soporte = fabrica.prism(tmp_path / "a", "1.20.4", "neoforge") + with Instancia(sin_soporte) as inst: + assert "no hay mod de apoyo" in mod_apoyo.estado(inst)[0].lower() + juego = fabrica.prism(tmp_path / "b", "1.20.1", "forge") + carpeta = _jars(tmp_path, "modpackes_runtime-forge-1.20.1-0.1.0.jar") + with Instancia(juego) as inst: + texto = " ".join(mod_apoyo.estado(inst, tmp_path / "vacia")) + assert "NO lo trae" in texto and "No está instalado" in texto and "Todavía no hay tabla" in texto + mod_apoyo.instalar(inst, carpeta) + tabla = juego / "resourcepacks" / "Traduccion_ES" / "assets" / "modpackes_runtime" / "textos" / "es_mx.json" + tabla.parent.mkdir(parents=True) + tabla.write_text("{}", encoding="utf-8") + texto = " ".join(mod_apoyo.estado(inst, carpeta)) + assert "lo trae" in texto and "modpackes_runtime-forge-1.20.1-0.1.0.jar" in texto and "Todo listo" in texto + + +def test_cli_cobertura_en_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + juego = _instancia_con_mod(tmp_path) + assert main(["cobertura", str(juego.parent), "--java", "--json"]) == 0 + salida = capsys.readouterr().out + datos = json.loads(salida) # solo JSON: sin cabecera ni líneas de texto + assert any(h["categoria"] == "java" and "You can't use chat" in " ".join(h["ejemplos"]) for h in datos) + + +def test_estado_en_quilt_avisa_que_es_el_mod_de_fabric(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1", "quilt") + with Instancia(juego) as inst: + texto = " ".join(mod_apoyo.estado(inst, tmp_path / "vacia")) + assert "fabric-1.20.1" in texto and "Quilted Fabric API" in texto and "no se ha probado en Quilt" in texto + + +def test_estado_en_neoforge_1_20_1_avisa_que_es_el_mod_de_forge(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1", "neoforge") + with Instancia(juego) as inst: + texto = " ".join(mod_apoyo.estado(inst, tmp_path / "vacia")) + assert "forge-1.20.1" in texto and "fork de Forge" in texto and "no se ha probado" in texto + + +def test_estado_avisa_si_el_jar_instalado_es_de_otra_version(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.21.1", "fabric") + (juego / "mods" / "modpackes_runtime-fabric-1.20.1-0.1.0.jar").write_bytes(b"viejo") + pack = juego / "resourcepacks" / "Traduccion_ES" / "assets" / "modpackes_runtime" / "textos" + pack.mkdir(parents=True) + (pack / "es_mx.json").write_text("{}", encoding="utf-8") + with Instancia(juego) as inst: + texto = " ".join(mod_apoyo.estado(inst, tmp_path / "vacia")) + assert "no es el de esta versión (fabric-1.21.1)" in texto and "Todo listo" not in texto + + +def test_instalar_no_borra_el_mod_viejo_si_la_copia_falla(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + juego = fabrica.prism(tmp_path, "1.20.1", "forge") + viejo = juego / "mods" / "modpackes_runtime-forge-1.20.1-0.0.9.jar" + viejo.write_bytes(b"viejo") + carpeta = _jars(tmp_path, "modpackes_runtime-forge-1.20.1-0.1.0.jar") + + def copia_que_falla(*_a: object, **_k: object) -> None: + raise OSError("disco lleno") + + monkeypatch.setattr(mod_apoyo.shutil, "copyfile", copia_que_falla) + with Instancia(juego) as inst, pytest.raises(OSError, match="disco lleno"): + mod_apoyo.instalar(inst, carpeta) + assert viejo.read_bytes() == b"viejo" # el que ya funcionaba sigue ahí diff --git a/tests/test_ui.py b/tests/test_ui.py index 2f86880..c47c3f5 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -465,3 +465,98 @@ def romper(_self: object) -> list[object]: _analizar(qtbot, v) assert not v.grupos["scripts"].incluido and "No se pudo leer" in v.grupos["scripts"].e_detalle.text() assert v.b_principal.isEnabled() + + +def test_que_queda_en_ingles_solo_lee(qtbot: QtBot, tmp_path: Path) -> None: + juego = _modpack(tmp_path) + ruta = juego / "kubejs" / "server_scripts" / "s.js" + ruta.write_text( + ruta.read_text("utf-8") + "\nlet nota = 'This sentence looks like a message but is not used'\n", + encoding="utf-8", + ) + antes = ruta.read_bytes() + v = _ventana(qtbot, juego) + v.a_cobertura.trigger() + qtbot.waitUntil(lambda: v._hilo is None and "lugares" in v.estado.text(), timeout=30000) # pyright: ignore[reportPrivateUsage] + assert "This sentence looks like a message" in v.log.toPlainText() + assert ruta.read_bytes() == antes and not (juego / "resourcepacks").exists() # solo lee + assert v.b_principal.isEnabled() # el botón principal sigue disponible + + +def test_sin_instancia_elegida_no_lanza_nada(qtbot: QtBot, tmp_path: Path) -> None: + v = Ventana(buscar_instancias=lambda: [], fabrica_motor=siempre(Simulador())) + qtbot.addWidget(v) + v.lanzar("analizar") + v.a_cobertura.trigger() + assert v._hilo is None and v.carpeta() is None # pyright: ignore[reportPrivateUsage] + + +def test_instancia_sin_version_detectable_se_recupera(qtbot: QtBot, tmp_path: Path) -> None: + juego = tmp_path / "Pack" / "minecraft" + (juego / "mods").mkdir(parents=True) # sin lanzador, sin log y sin mods con versión + v = _ventana(qtbot, juego) + v.b_principal.click() + qtbot.waitUntil(lambda: v._hilo is None and "Se detuvo" in v.estado.text(), timeout=30000) # pyright: ignore[reportPrivateUsage] + assert v.b_principal.isEnabled() and "Traceback" not in v.log.toPlainText() + + +def test_cobertura_sin_nada_que_decir(qtbot: QtBot, tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + fabrica.jar(juego / "mods" / "m.jar", fabrica.lang("m", "en_us", {"item.m.a": "Iron Plate"})) + v = _ventana(qtbot, juego) + v.a_cobertura.trigger() + qtbot.waitUntil(lambda: v._hilo is None and "No encontré" in v.estado.text(), timeout=30000) # pyright: ignore[reportPrivateUsage] + + +def test_soltar_rutas_que_no_son_carpetas(qtbot: QtBot, tmp_path: Path) -> None: + from PySide6.QtCore import QMimeData, QUrl + + archivo = tmp_path / "x.txt" + archivo.write_text("x", encoding="utf-8") + datos = QMimeData() + datos.setUrls( + [QUrl.fromLocalFile(str(archivo)), QUrl.fromLocalFile(str(tmp_path / "no_existe")), QUrl("https://x.y/z")] + ) + assert Ventana.carpetas_soltadas(datos) == [] + datos.setUrls([QUrl.fromLocalFile(str(tmp_path))]) + assert Ventana.carpetas_soltadas(datos) == [str(tmp_path)] + + +def test_opcion_de_texto_java_y_mod_de_apoyo_en_la_ventana( + qtbot: QtBot, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from traductor_es.salida import mod_apoyo + + vacia = tmp_path / "sin_jars" + vacia.mkdir() + monkeypatch.setattr(mod_apoyo, "_carpeta_de_jars", lambda: vacia) # sin importar si esta copia ya compiló el jar + juego = _modpack(tmp_path) + v = _ventana(qtbot, juego) + assert not v.c_java.isChecked() and v.opciones()["texto_java"] is False # apagada por defecto + v.c_java.setChecked(True) + assert v.opciones()["texto_java"] is True + v.a_apoyo.trigger() # Forge 1.20.1 sí tiene mod de apoyo, pero sin el jar empaquetado debe avisar, no romper + assert "no trae el mod de apoyo" in v.aviso.e_texto.text() + assert not list((juego / "mods").glob("modpackes_runtime-*.jar")) + v.a_quitar_apoyo.trigger() + assert "No había mod de apoyo" in v.aviso.e_texto.text() + + +def test_informe_en_cuadro_y_guardado_en_archivo(qtbot: QtBot, tmp_path: Path) -> None: + juego = _modpack(tmp_path) + ruta = juego / "kubejs" / "server_scripts" / "s.js" + ruta.write_text( + ruta.read_text("utf-8") + "\nlet nota = 'This sentence looks like a message but is not used'\n", + encoding="utf-8", + ) + v = _ventana(qtbot, juego) + v._guardar_informe(str(tmp_path / "antes.txt")) # pyright: ignore[reportPrivateUsage] + assert not (tmp_path / "antes.txt").exists() # todavía no hay informe: no escribe nada + v.a_cobertura.trigger() + qtbot.waitUntil(lambda: v._hilo is None and v._dialogo_informe is not None, timeout=30000) # pyright: ignore[reportPrivateUsage] + assert "This sentence looks like a message" in v._dialogo_informe.texto.toPlainText() # pyright: ignore[reportPrivateUsage, reportOptionalMemberAccess] + destino = tmp_path / "informe.txt" + v._guardar_informe(str(destino)) # pyright: ignore[reportPrivateUsage] + assert "This sentence looks like a message" in destino.read_text("utf-8") + v.a_comprobar_apoyo.trigger() + assert "mod de apoyo" in v.aviso.e_texto.text().lower() or "mod de apoyo" in v.log.toPlainText().lower() diff --git a/tests/test_verificacion_formatos.py b/tests/test_verificacion_formatos.py new file mode 100644 index 0000000..3cdd9ad --- /dev/null +++ b/tests/test_verificacion_formatos.py @@ -0,0 +1,205 @@ +"""Casos límite de los formatos: idioma JSON y .lang, JSON/SNBT/JS/NBT/markdown con entradas raras, y UTF-8.""" + +import contextlib +import json + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from traductor_es.formatos import ( + comandos, + componente, + javascript, + json_cadenas, + json_texto, + lang_json, + lang_legacy, + markdown, + nbt, + snbt, +) + +TEXTO = st.text(st.characters(blacklist_categories=("Cs",)), max_size=40) +SEPARADORES = "\r\n\x85" + chr(0x2028) + chr(0x2029) + chr(0xFEFF) # saltos de línea y BOM +SIN_SALTOS = st.text(st.characters(blacklist_categories=("Cs",), blacklist_characters=SEPARADORES), max_size=40) +SUAVE = settings(max_examples=200, suppress_health_check=[HealthCheck.too_slow], deadline=None) + + +# ------------------------------------------------------------------ lang JSON +@SUAVE +@given(st.dictionaries(TEXTO, TEXTO, max_size=8)) +def test_lang_json_ida_y_vuelta_y_utf8(textos: dict[str, str]) -> None: + datos = lang_json.escribir(textos) + assert lang_json.leer(datos) == textos + assert json.loads(datos.decode("utf-8")) == textos # es JSON válido y UTF-8 puro + assert not datos.startswith(b"\xef\xbb\xbf") # sin BOM: el juego no lo quiere + + +def test_lang_json_guarda_tildes_y_ene_sin_escapar() -> None: + datos = lang_json.escribir({"a": "¡Ñandú, canción y pingüino!"}) + assert "¡Ñandú, canción y pingüino!".encode() in datos and b"\\u" not in datos + + +@pytest.mark.parametrize( + "crudo", + [ + b'\xef\xbb\xbf{"a": "b"}', # con BOM + b'{"a": "b",}', # coma final + b'{"a": "b" /* comentario */}', + b"{'a': 'b'}", # comillas simples + b'{"a": "b", "n": 5, "l": [1], "o": {"x": "y"}, "z": null}', # solo se conservan los textos + ], +) +def test_lang_json_tolerante(crudo: bytes) -> None: + assert lang_json.leer(crudo)["a"] == "b" + + +@pytest.mark.parametrize("crudo", [b"[1, 2]", b'"texto"', b"5"]) +def test_lang_json_que_no_es_objeto_falla_con_valueerror(crudo: bytes) -> None: + with pytest.raises(ValueError, match="no es un objeto"): + lang_json.leer(crudo) + + +@pytest.mark.parametrize("crudo", [b"", b"{", b'{"a": ', b"no es json", b"\xff\xfe\x00\x00"]) +def test_lang_json_roto_falla_con_valueerror(crudo: bytes) -> None: + with pytest.raises(ValueError): + lang_json.leer(crudo) + + +# ------------------------------------------------------------------ .lang antiguo +@SUAVE +@given( + st.dictionaries(SIN_SALTOS.filter(lambda k: "=" not in k and not k.startswith("#") and k), SIN_SALTOS, max_size=8) +) +def test_lang_legacy_ida_y_vuelta(textos: dict[str, str]) -> None: + datos = lang_legacy.escribir(textos) + assert lang_legacy.leer(datos) == textos + datos.decode("utf-8") + + +def test_lang_legacy_detalles() -> None: + crudo = "# comentario\r\nitem.a=Espada §a%s\r\n\r\nsin_igual\r\nitem.b=a=b=c\nitem.c=\n".encode() + assert lang_legacy.leer(crudo) == {"item.a": "Espada §a%s", "item.b": "a=b=c", "item.c": ""} + with pytest.raises(ValueError, match="salto de línea"): + lang_legacy.escribir({"k": "dos\nlíneas"}) + with pytest.raises(ValueError, match="salto de línea"): + lang_legacy.escribir({"k=v": "x"}) + + +# ------------------------------------------------------------------ escáneres: nunca fallan con otra cosa que ValueError +@SUAVE +@given(st.text(max_size=120)) +def test_escaneres_solo_lanzan_valueerror(texto: str) -> None: + for escaner in (javascript.escanear, snbt.escanear, json_cadenas.escanear): + try: + cadenas = escaner(texto) + except ValueError: + continue + assert isinstance(cadenas, list) + + +@SUAVE +@given(st.text(alphabet="{}[]\"':,/\\ ab1\n`$()+-*=", max_size=80)) +def test_escaneres_con_sintaxis_hostil(texto: str) -> None: + for escaner in (javascript.escanear, snbt.escanear, json_cadenas.escanear): + with contextlib.suppress(ValueError): + escaner(texto) + + +@SUAVE +@given(st.text(max_size=100)) +def test_componentes_y_comandos_no_fallan(texto: str) -> None: + json_texto.cargar(texto) + componente.unidades(texto) + assert componente.reconstruir(texto, {}) == texto # sin traducciones no cambia nada + comandos.analizar(texto) + comandos.analizar("tellraw @a " + texto) + comandos.analizar("execute as @a run say " + texto) + + +def test_json_cadenas_unicode_y_escapes() -> None: + src = '{"a": "ca\\u00f1\\u00f3n \\"x\\" \\n fin", "b": ["\\ud83d\\ude00", "plano"]}' + cadenas = json_cadenas.escanear(src) + assert [c.texto for c in cadenas] == ['cañón "x" \n fin', "😀", "plano"] + nuevo = json_cadenas.reemplazar(src, cadenas, {cadenas[2].inicio: "llano ñ"}) + assert json.loads(nuevo)["b"][1] == "llano ñ" and json.loads(nuevo)["a"] == json.loads(src)["a"] + + +@SUAVE +@given(TEXTO) +def test_json_cadenas_reemplazo_arbitrario_es_json_valido(texto: str) -> None: + src = '{"k": "valor", "l": ["x"]}' + cadenas = json_cadenas.escanear(src) + nuevo = json_cadenas.reemplazar(src, cadenas, {cadenas[0].inicio: texto, cadenas[1].inicio: texto}) + assert json.loads(nuevo) == {"k": texto, "l": [texto]} + + +# ------------------------------------------------------------------ JavaScript +@SUAVE +@given(TEXTO, st.sampled_from("'\"`")) +def test_js_escapar_ida_y_vuelta_con_especiales(texto: str, comilla: str) -> None: + if comilla == "`" and "${" in texto: + return + src = f"x.tell({comilla}{javascript.escapar(texto, comilla)}{comilla})\n" + assert [c.texto for c in javascript.escanear(src)] == [texto] + + +@pytest.mark.parametrize( + "src", + ["x.tell('sin cerrar", 'x.tell("sin cerrar\n")', "x.tell(`sin ${cerrar`)"], +) +def test_js_cadena_sin_cerrar_es_valueerror(src: str) -> None: + with pytest.raises(ValueError, match="sin cerrar"): + javascript.escanear(src) + + +def test_js_crlf_comentarios_regex_y_division() -> None: + src = "let a = 10 / 2 / 5\r\n// 'no'\r\nlet r = /'x'/g; /* \"no\" */ x.tell('sí ñ')\r\n" + assert [c.texto for c in javascript.escanear(src)] == ["sí ñ"] + + +# ------------------------------------------------------------------ SNBT / NBT +def test_snbt_escapes_y_unicode() -> None: + src = r'{title: "Hola \"mundo\" ñ\n", list: ["a", "b"]}' + cadenas = snbt.escanear(src) + nuevo = snbt.reemplazar(src, cadenas, {cadenas[0].inicio: 'Adiós "mundo" ¡ñ!'}) + assert next(c.texto for c in snbt.escanear(nuevo)) == 'Adiós "mundo" ¡ñ!' and '"a", "b"' in nuevo + + +@pytest.mark.parametrize( + "datos", [b"", b"\x00", b"\x0a\x00", b"\x1f\x8b\x08garbage", b"\x0a\x00\x00\x08\x00\x01a\xff\xff"] +) +def test_nbt_basura_falla_con_valueerror(datos: bytes) -> None: + with pytest.raises(ValueError): + nbt.leer(datos) + + +@SUAVE +@given(st.binary(max_size=60)) +def test_nbt_bytes_cualesquiera_no_revientan(datos: bytes) -> None: + with contextlib.suppress(ValueError): + nbt.leer(datos) + + +@SUAVE +@given(TEXTO) +def test_mutf8_ida_y_vuelta(texto: str) -> None: + assert nbt.mutf8_decodificar(nbt.mutf8_codificar(texto)) == texto + + +# ------------------------------------------------------------------ markdown +@SUAVE +@given(st.text(alphabet="abc ñ#-*>`~!\n\r\t[]()$<>=", max_size=120)) +def test_markdown_sin_traducir_no_cambia_nada(md: str) -> None: + lineas, segs = markdown.segmentar(md) + assert markdown.reconstruir(lineas, segs, {}) == md + + +def test_markdown_traduce_sin_tocar_codigo_ni_crlf() -> None: + md = "# Title here\r\n\r\nSome text about iron.\r\n\r\n```\r\ncode stays\r\n```\r\n- list item here\r\n" + lineas, segs = markdown.segmentar(md) + trad = {s.texto: "«ES» " + s.texto for s in segs} + salida = markdown.reconstruir(lineas, segs, trad) + assert "code stays" in salida and "«ES» Some text about iron." in salida + assert salida.count("\r\n") == md.count("\r\n") diff --git a/tests/test_verificacion_fuentes.py b/tests/test_verificacion_fuentes.py new file mode 100644 index 0000000..cc24771 --- /dev/null +++ b/tests/test_verificacion_fuentes.py @@ -0,0 +1,180 @@ +"""Casos límite de las fuentes: instancias rotas o vacías, jars dañados, options.txt raro y versiones inesperadas.""" + +import json +import zipfile +from pathlib import Path + +import pytest + +from traductor_es.fuentes import opciones +from traductor_es.fuentes.contenedores import Carpeta, Zip, abrir_mod +from traductor_es.fuentes.deteccion import detectar +from traductor_es.fuentes.instancia import Instancia, resolver_carpeta_juego +from traductor_es.fuentes.version import Version, formato_datos, formato_recursos + +from . import fabrica + + +# ------------------------------------------------------------------ versiones +@pytest.mark.parametrize( + ("texto", "partes"), + [ + ("1.20.1", (1, 20, 1)), + ("1.20", (1, 20, 0)), + ("1.12.2-forge", (1, 12, 2)), + (" 26.1 ", (26, 1, 0)), + ("1.21.11", (1, 21, 11)), + ], +) +def test_version_se_lee_de_formas_distintas(texto: str, partes: tuple[int, ...]) -> None: + assert Version.de(texto).partes == partes + + +@pytest.mark.parametrize("texto", ["", "abc", "1", "v1.2", "..", "latest"]) +def test_version_invalida_falla_con_valueerror(texto: str) -> None: + with pytest.raises(ValueError, match="no reconocida"): + Version.de(texto) + + +def test_formatos_de_pack_son_monotonos_y_cubren_todo() -> None: + versiones = [ + "1.6", + "1.9", + "1.11", + "1.13", + "1.16.2", + "1.18", + "1.19.4", + "1.20", + "1.20.5", + "1.21", + "1.21.5", + "26.1", + "26.3", + ] + previos = [formato_recursos(Version.de(v)) for v in versiones] + assert previos == sorted(previos) # el formato nunca baja al subir la versión + assert formato_recursos(Version.de("1.5")) == (1, 0) # versiones muy viejas: valor por defecto + assert formato_datos(Version.de("1.12")) is None and formato_datos(Version.de("1.13")) == (4, 0) + + +# ------------------------------------------------------------------ carpetas y detección +def test_carpeta_que_no_existe_o_vacia(tmp_path: Path) -> None: + assert resolver_carpeta_juego(tmp_path / "no_existe") is None + vacia = tmp_path / "vacia" + vacia.mkdir() + with pytest.raises(ValueError): + Instancia(vacia) + + +def test_resolver_carpeta_acepta_la_instancia_o_su_minecraft(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + assert resolver_carpeta_juego(juego) == juego + assert resolver_carpeta_juego(juego.parent) == juego + + +def test_deteccion_con_archivos_de_configuracion_rotos(tmp_path: Path) -> None: + juego = tmp_path / "Pack" / "minecraft" + juego.mkdir(parents=True) + (juego.parent / "mmc-pack.json").write_text("{esto no es json", encoding="utf-8") + (juego / "minecraftinstance.json").write_bytes(b"\xff\xfe\x00basura") + (juego / "mods").mkdir() + (juego / "mods" / "roto.jar").write_bytes(b"no es un zip") + with pytest.raises(ValueError): + Instancia(juego) # sin versión: dice que no la pudo detectar, no revienta con otro error + + +def test_deteccion_ignora_lo_que_no_es_cadena(tmp_path: Path) -> None: + juego = tmp_path / "Pack" / "minecraft" + (juego / "mods").mkdir(parents=True) + (juego.parent / "mmc-pack.json").write_text( + json.dumps({"components": [None, 5, {"uid": 3}, {"uid": "net.minecraft"}]}) + ) + d = detectar(juego, []) # no revienta: simplemente no encuentra la versión + assert d.version is None and d.origen == "no se pudo detectar" + + +# ------------------------------------------------------------------ options.txt +@pytest.mark.parametrize( + ("contenido", "idioma", "packs"), + [ + ('lang:es_mx\nresourcePacks:["vanilla","file/Mi Pack","mod_resources"]\n', "es_mx", ("Mi Pack",)), + ("lang:\n", None, ()), + ("resourcePacks:esto no es json\n", None, ()), + ('resourcePacks:{"a": 1}\n', None, ()), # JSON válido pero no es una lista + ('lang:EN_US\r\nresourcePacks:["file/Ñandú ¡pack!"]\r\n', "en_us", ("Ñandú ¡pack!",)), + ("", None, ()), + ], +) +def test_options_txt(tmp_path: Path, contenido: str, idioma: str | None, packs: tuple[str, ...]) -> None: + (tmp_path / "options.txt").write_text(contenido, encoding="utf-8", newline="") + o = opciones.leer(tmp_path) + assert o.idioma == idioma and o.packs == packs + + +def test_options_txt_inexistente_o_con_bytes_invalidos(tmp_path: Path) -> None: + assert opciones.leer(tmp_path) == opciones.Opciones() + (tmp_path / "options.txt").write_bytes(b"lang:es_mx\n\xff\xfe\x00 basura \x80\n") + assert opciones.leer(tmp_path).idioma == "es_mx" + + +# ------------------------------------------------------------------ contenedores y jars +def test_jar_danado_no_detiene_la_lectura(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + (juego / "mods" / "roto.jar").write_bytes(b"PK\x03\x04 truncado") + fabrica.jar(juego / "mods" / "bueno.jar", fabrica.lang("m", "en_us", {"item.m.a": "Iron Plate"})) + with Instancia(juego) as inst: + tabla = inst.tabla_idiomas() + assert tabla.textos("m", "en_us")["item.m.a"] == "Iron Plate" + + +def test_jar_con_nombres_unicode_y_lang_con_ene(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + fabrica.jar(juego / "mods" / "Ñandú ¡mod! 日本.jar", fabrica.lang("ñ", "en_us", {"item.a": "Plátano ñ"})) + with Instancia(juego) as inst: + assert [c.nombre for c in inst.contenedores_mods()] == ["Ñandú ¡mod! 日本.jar"] + + +def test_zip_con_entradas_peligrosas_no_se_extrae(tmp_path: Path) -> None: + ruta = tmp_path / "m.jar" + with zipfile.ZipFile(ruta, "w") as z: + z.writestr("../../fuera.txt", "x") + z.writestr("assets/m/lang/en_us.json", '{"a": "b"}') + contenedores = list(abrir_mod(ruta)) + try: + assert contenedores and contenedores[0].leer("assets/m/lang/en_us.json") == b'{"a": "b"}' + finally: + for c in contenedores: + c.cerrar() + assert not (tmp_path.parent / "fuera.txt").exists() # solo se lee en memoria: nada se escribe en disco + + +def test_jars_anidados_con_limite_de_profundidad(tmp_path: Path) -> None: + interior = fabrica.zip_en_memoria(fabrica.lang("m", "en_us", {"a": "Hello there"})) + for _ in range(6): # un jar dentro de un jar dentro de otro… + interior = fabrica.zip_en_memoria({"META-INF/jarjar/x.jar": interior}) + ruta = tmp_path / "profundo.jar" + ruta.write_bytes(interior) + contenedores = list(abrir_mod(ruta)) # no debe colgarse ni lanzar + for c in contenedores: + c.cerrar() + assert len(contenedores) >= 1 + + +def test_carpeta_como_contenedor_ignora_lo_que_sale_de_la_raiz(tmp_path: Path) -> None: + (tmp_path / "pack" / "assets" / "m" / "lang").mkdir(parents=True) + (tmp_path / "pack" / "assets" / "m" / "lang" / "en_us.json").write_text('{"a": "b"}', encoding="utf-8") + (tmp_path / "secreto.txt").write_text("no", encoding="utf-8") + c = Carpeta(tmp_path / "pack") + assert "assets/m/lang/en_us.json" in c.nombres() + for fuera in ("../secreto.txt", "assets/../../secreto.txt", str(tmp_path / "secreto.txt")): + with pytest.raises(KeyError): + c.leer(fuera) + assert c.leer("assets/m/lang/en_us.json") == b'{"a": "b"}' + + +def test_zip_abrir_un_archivo_que_no_es_zip(tmp_path: Path) -> None: + ruta = tmp_path / "x.zip" + ruta.write_bytes(b"nada") + with pytest.raises(zipfile.BadZipFile): + Zip.abrir(ruta) diff --git a/tests/test_verificacion_modulos.py b/tests/test_verificacion_modulos.py new file mode 100644 index 0000000..9a7b7d2 --- /dev/null +++ b/tests/test_verificacion_modulos.py @@ -0,0 +1,299 @@ +"""Casos límite de los módulos: lang con textos difíciles, scripts con codificaciones raras y archivos dañados.""" + +import json +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from traductor_es.formatos import lang_json, lang_legacy +from traductor_es.fuentes import vanilla +from traductor_es.fuentes.instancia import Instancia +from traductor_es.modulos.base import Contexto, traducir_modulo +from traductor_es.modulos.libros import Libros +from traductor_es.modulos.misiones import HQM, BetterQuesting, FTBQuests, Heracles +from traductor_es.modulos.mods import Mods +from traductor_es.modulos.scripts import ScriptsKubeJS +from traductor_es.motores.simulador import Simulador +from traductor_es.nucleo.dialectos import VARIANTES +from traductor_es.nucleo.glosario import Glosario +from traductor_es.nucleo.mascara import enmascarar +from traductor_es.nucleo.memoria import Memoria +from traductor_es.nucleo.traductor import Traductor +from traductor_es.salida.copias import Respaldo +from traductor_es.salida.resource_pack import PackSalida + +from . import fabrica + +DIFICILES = { + "item.m.a": "Iron Plate", + "item.m.b": "§aGreen %s with %d items §r and %1$s", + "item.m.c": 'Say "hello" \\ back\\slash and {0} and ${x}', + "item.m.d": "Línea uno\nLínea dos con tab\tfin", + "item.m.e": "Ñandú ¡Ágüita! cañón 日本語 😀 click here", + "item.m.f": "Visit https://example.com/a_b?x=1&y=2 now please", + "item.m.g": "%s", # solo un código: no hay nada que traducir + "item.m.h": "Bold text and
a break", +} + + +@pytest.fixture +def memoria() -> Iterator[Memoria]: + with Memoria(":memory:") as m: + yield m + + +@pytest.fixture(autouse=True) +def _sin_lanzadores(monkeypatch: pytest.MonkeyPatch) -> None: + + def sin_lanzadores() -> list[Path]: + return [] # sin el juego real del PC + + monkeypatch.setattr(vanilla, "raices_de_lanzadores", sin_lanzadores) + + +def _traducir_mods(juego: Path, memoria: Memoria) -> Path: + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego), pack=PackSalida(inst)) + resumen = traducir_modulo(Mods(ctx), Traductor(Simulador(), memoria, Glosario())) + assert resumen.archivos + return ctx.pack_listo().raiz + + +def test_mods_modernos_producen_json_valido_con_los_mismos_codigos(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + fabrica.jar(juego / "mods" / "m.jar", fabrica.lang("m", "en_us", DIFICILES)) + pack = _traducir_mods(juego, memoria) + json.loads((pack / "pack.mcmeta").read_text(encoding="utf-8")) + for variante in VARIANTES: + ruta = pack / "assets" / "m" / "lang" / f"{variante}.json" + crudo = ruta.read_bytes() + crudo.decode("utf-8") # UTF-8 puro + assert not crudo.startswith(b"\xef\xbb\xbf") + textos = json.loads(crudo) # JSON válido + assert set(textos) <= set(DIFICILES) + for clave, valor in textos.items(): + assert sorted(enmascarar(valor).tokens) == sorted(enmascarar(DIFICILES[clave]).tokens), (variante, clave) + assert textos["item.m.e"] == "«ES» Ñandú ¡Ágüita! cañón 日本語 😀 click here" + assert "item.m.g" not in textos # sin nada que traducir no se inventa nada + + +def test_mods_legacy_lang_sin_saltos_de_linea_reales(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.12.2") + textos = {k: v for k, v in DIFICILES.items() if "\n" not in v} + fabrica.jar(juego / "mods" / "m.jar", fabrica.lang("m", "en_us", textos, legacy=True)) + pack = _traducir_mods(juego, memoria) + for ruta in (pack / "assets" / "m" / "lang").glob("*.lang"): + crudo = ruta.read_bytes() + crudo.decode("utf-8") + leidos = lang_legacy.leer(crudo) + assert set(leidos) <= set(textos) + for clave, valor in leidos.items(): + assert sorted(enmascarar(valor).tokens) == sorted(enmascarar(textos[clave]).tokens) + assert any((pack / "assets" / "m" / "lang").glob("*.lang")) + + +def test_lang_vacio_roto_o_sin_texto_no_detiene_a_los_demas(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + fabrica.jar(juego / "mods" / "vacio.jar", {"assets/v/lang/en_us.json": "{}"}) + fabrica.jar(juego / "mods" / "roto.jar", {"assets/r/lang/en_us.json": '{"a": "Hello there'}) + fabrica.jar(juego / "mods" / "mixto.jar", {"assets/x/lang/en_us.json": '{"a": 5, "b": null, "c": "Hello there"}'}) + fabrica.jar(juego / "mods" / "bueno.jar", fabrica.lang("m", "en_us", {"item.m.a": "Iron Plate"})) + pack = _traducir_mods(juego, memoria) + assert json.loads((pack / "assets" / "m" / "lang" / "es_mx.json").read_text("utf-8")) == { + "item.m.a": "«ES» Iron Plate" + } + assert json.loads((pack / "assets" / "x" / "lang" / "es_mx.json").read_text("utf-8")) == {"c": "«ES» Hello there"} + + +# ------------------------------------------------------------------ scripts +def _script(tmp_path: Path, contenido: bytes) -> tuple[Path, Path]: + juego = fabrica.prism(tmp_path, "1.20.1") + ruta = juego / "kubejs" / "server_scripts" / "s.js" + ruta.parent.mkdir(parents=True) + ruta.write_bytes(contenido) + return juego, ruta + + +def _correr(juego: Path, memoria: Memoria, avisos: list[str] | None = None) -> int: + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego), registro=(avisos.append if avisos is not None else lambda _m: None)) + return traducir_modulo(ScriptsKubeJS(ctx), Traductor(Simulador(), memoria, Glosario())).traducidos + + +def test_script_con_bom_y_crlf_se_conserva(tmp_path: Path, memoria: Memoria) -> None: + fuente = "PlayerEvents.chat(e => {\r\n e.player.tell('Hello there ñ')\r\n})\r\n".encode() + juego, ruta = _script(tmp_path, fuente) + assert _correr(juego, memoria) == 1 + nuevo = ruta.read_bytes() + assert nuevo.startswith(b"\xef\xbb\xbf") and nuevo.count(b"\r\n") == 3 and "«ES» Hello there ñ".encode() in nuevo + + +@pytest.mark.parametrize( + "contenido", + [ + b"", + b"\n\n", + b"// solo un comentario 'x'\n", + b"let a = 'sin cerrar\n", + b"x.tell('\xe9\xe9 latin-1')\n", + b"\x00\x01\x02", + b"`${", + ], +) +def test_script_vacio_roto_o_con_otra_codificacion_se_deja_igual( + tmp_path: Path, memoria: Memoria, contenido: bytes +) -> None: + juego, ruta = _script(tmp_path, contenido) + avisos: list[str] = [] + assert _correr(juego, memoria, avisos) == 0 + assert ruta.read_bytes() == contenido + + +def test_script_enorme(tmp_path: Path, memoria: Memoria) -> None: + cuerpo = "".join(f"e.player.tell('Mensaje número {i} para ti')\n" for i in range(5000)).encode() + juego, ruta = _script(tmp_path, cuerpo) + assert _correr(juego, memoria) == 5000 + assert ruta.read_text("utf-8").count("«ES»") == 5000 + + +# ------------------------------------------------------------------ misiones y libros dañados +@pytest.mark.parametrize( + ("modulo", "ruta", "contenido"), + [ + (FTBQuests, "config/ftbquests/quests/chapters/a.snbt", b'{ title: "sin cerrar '), + (FTBQuests, "config/ftbquests/quests/chapters/b.snbt", b"\xff\xfe\x00 no utf8"), + (FTBQuests, "config/ftbquests/quests/chapters/c.nbt", b"\x1f\x8b\x08 gzip roto"), + (BetterQuesting, "config/betterquesting/DefaultQuests.json", b'{"questDatabase:9": {'), + (HQM, "config/hqm/quests.json", b"no es json"), + (Heracles, "config/heracles/quests/q.json", b"\xff\xfe"), + ], +) +def test_mision_danada_se_deja_igual_sin_reventar( + tmp_path: Path, memoria: Memoria, modulo: type, ruta: str, contenido: bytes +) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + destino = juego / ruta + destino.parent.mkdir(parents=True) + destino.write_bytes(contenido) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + m = modulo(ctx) + if m.detectar(): + traducir_modulo(m, Traductor(Simulador(), memoria, Glosario())) + assert destino.read_bytes() == contenido + + +def test_libro_con_json_roto_y_libro_bueno(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + libros = juego / "patchouli_books" / "guia" + (libros / "en_us" / "entries").mkdir(parents=True) + (libros / "book.json").write_text( + json.dumps({"name": "Guide book", "landing_text": "Welcome to the guide."}), "utf-8" + ) + (libros / "en_us" / "entries" / "roto.json").write_text('{"name": "Roto', "utf-8") + (libros / "en_us" / "entries" / "bueno.json").write_text( + json.dumps( + { + "name": "Good entry", + "category": "x", + "pages": [{"type": "patchouli:text", "text": "Hello $(l)there$()."}], + } + ), + "utf-8", + ) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego), pack=PackSalida(inst)) + modulo = Libros(ctx) + if modulo.detectar(): + traducir_modulo(modulo, Traductor(Simulador(), memoria, Glosario())) + assert (libros / "en_us" / "entries" / "roto.json").read_text("utf-8") == '{"name": "Roto' + salida = json.dumps(lang_json.leer("{}")) # el lector de lang sigue sano + assert salida == "{}" + + +# ------------------------------------------------------------------ datos (funciones, logros, tips) raros +def test_datos_con_formas_inesperadas_no_revientan(tmp_path: Path, memoria: Memoria) -> None: + from traductor_es.modulos.datos import Datos + + juego = fabrica.prism(tmp_path, "1.20.1") + base = juego / "kubejs" / "data" / "a" + casos = { + "advancements/texto.json": json.dumps({"display": "no es un objeto"}), + "advancements/lista.json": json.dumps(["x", {"display": {"title": "Hi there"}}]), + "advancements/nulos.json": json.dumps({"display": {"title": None, "description": 5}}), + "advancements/bom.json": "\ufeff" + + json.dumps({"display": {"title": "Big Win", "description": {"translate": "k"}}}), + "functions/macro.mcfunction": '$tellraw @a {"text":"$(x) hello there"}\n', + "functions/vacia.mcfunction": "", + "functions/latin.mcfunction": None, # bytes que no son UTF-8 + } + for rel, contenido in casos.items(): + ruta = base / rel + ruta.parent.mkdir(parents=True, exist_ok=True) + if contenido is None: + ruta.write_bytes(b"say \xe9\xe9 hola\n") + else: + ruta.write_text(contenido, encoding="utf-8") + antes = {p: p.read_bytes() for p in base.rglob("*") if p.is_file()} + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + traducir_modulo(Datos(ctx), Traductor(Simulador(), memoria, Glosario())) + despues = {p: p.read_bytes() for p in base.rglob("*") if p.is_file()} + cambiados = sorted(p.name for p in antes if antes[p] != despues[p]) + assert cambiados == ["bom.json"] # solo el logro bueno: lo raro se deja igual + assert json.loads((base / "advancements" / "bom.json").read_text("utf-8"))["display"]["title"] == "«ES» Big Win" + + +def test_zip_danado_se_ignora(tmp_path: Path, memoria: Memoria) -> None: + from traductor_es.modulos.datos import Datos + + juego = fabrica.prism(tmp_path, "1.20.1") + (juego / "datapacks").mkdir() + (juego / "datapacks" / "roto.zip").write_bytes(b"PK\x03\x04 no es un zip de verdad") + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + assert not Datos(ctx).detectar() + + +@pytest.mark.parametrize("namespace", ["..", "a:b", "nul", "COM1.x", "con.", "trailing ", "a|b"]) +def test_namespace_imposible_no_tumba_el_resto(tmp_path: Path, memoria: Memoria, namespace: str) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + avisos: list[str] = [] + fabrica.jar( + juego / "mods" / "x.jar", + { + f"assets/{namespace}/lang/en_us.json": '{"a": "Hello there"}', + **fabrica.lang("ok", "en_us", {"b": "Good one"}), + }, + ) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego), pack=PackSalida(inst), registro=avisos.append) + resumen = traducir_modulo(Mods(ctx), Traductor(Simulador(), memoria, Glosario())) + pack = ctx.pack_listo().raiz + assert json.loads((pack / "assets" / "ok" / "lang" / "es_mx.json").read_text("utf-8")) == {"b": "«ES» Good one"} + assert resumen.pendientes >= 1 and any("no se puede guardar" in a for a in avisos) + assert not (tmp_path / "Pack" / "minecraft" / "lang").exists() # nada se escribió fuera del pack + + +EXHAUSTIVO = """const aviso = 'The harvest festival starts tomorrow evening' +console.log('This is only a debug line for the log here') +if (nombre == 'Some Name With Spaces') return +function f(e) { e.player.tell('Welcome to the farm') } +""" + + +def test_modo_exhaustivo_traduce_mas_pero_no_los_registros_ni_las_comparaciones( + tmp_path: Path, memoria: Memoria +) -> None: + juego, ruta = _script(tmp_path, EXHAUSTIVO.encode()) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + normal = traducir_modulo(ScriptsKubeJS(ctx), Traductor(Simulador(), memoria, Glosario())) + assert normal.traducidos == 1 # solo el `tell` + ScriptsKubeJS(ctx).restaurar() + todo = traducir_modulo(ScriptsKubeJS(ctx, exhaustivo=True), Traductor(Simulador(), memoria, Glosario())) + nuevo = ruta.read_text("utf-8") + assert todo.traducidos == 2 and "'«ES» The harvest festival starts tomorrow evening'" in nuevo + assert "'This is only a debug line for the log here'" in nuevo # console.log no es para el jugador + assert "'Some Name With Spaces'" in nuevo # una comparación es lógica diff --git a/tests/test_verificacion_motores.py b/tests/test_verificacion_motores.py new file mode 100644 index 0000000..c0114d9 --- /dev/null +++ b/tests/test_verificacion_motores.py @@ -0,0 +1,191 @@ +"""Casos límite de los motores: respuestas raras o rotas, errores de red, cancelación y creación con datos faltantes.""" + +import json +import threading +from collections.abc import Callable, Iterator +from typing import Any + +import httpx +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from traductor_es.motores import fabrica +from traductor_es.motores.base import Cancelado, Entrada, ErrorFatal, Lote +from traductor_es.motores.compatible import PERFILES, Compatible +from traductor_es.motores.instrucciones import interpretar, sistema, usuario +from traductor_es.motores.simulador import Simulador + +SUAVE = settings(max_examples=200, suppress_health_check=[HealthCheck.too_slow], deadline=None) + + +def lote(*textos: str, tarea: str = "traducir") -> Lote: + return Lote(tuple(Entrada(str(i), t) for i, t in enumerate(textos)), "ui", "español", {}, "", tarea) + + +@pytest.fixture +def armar() -> Iterator[Callable[..., tuple[Compatible, list[httpx.Request], list[float]]]]: + abiertos: list[Compatible] = [] + + def crear( + *respuestas: httpx.Response | Exception, **kw: Any + ) -> tuple[Compatible, list[httpx.Request], list[float]]: + pedidos: list[httpx.Request] = [] + esperas: list[float] = [] + cola = list(respuestas) + + def manejar(pedido: httpx.Request) -> httpx.Response: + pedidos.append(pedido) + r = cola.pop(0) if len(cola) > 1 else cola[0] + if isinstance(r, Exception): + raise r + return r + + cliente = httpx.Client(transport=httpx.MockTransport(manejar)) + m = Compatible(PERFILES["openrouter"], "modelo", cliente=cliente, esperar=esperas.append, clave="sk-x", **kw) + abiertos.append(m) + return m, pedidos, esperas + + yield crear + for m in abiertos: + m.cerrar() + + +# ------------------------------------------------------------------ interpretar +@SUAVE +@given(st.one_of(st.none(), st.text(max_size=200))) +def test_interpretar_nunca_falla_y_siempre_devuelve_textos(texto: str | None) -> None: + resultado = interpretar(texto) + assert isinstance(resultado, dict) and all(isinstance(k, str) and isinstance(v, str) for k, v in resultado.items()) + + +@pytest.mark.parametrize( + ("crudo", "esperado"), + [ + ('{"0": "Hola ñ"}', {"0": "Hola ñ"}), + ('```json\n{"0": "Hola"}\n```', {"0": "Hola"}), + ('```\n{"0": "Hola"}\n```', {"0": "Hola"}), + (' {"0": "Hola", "1": 5, "2": null, "3": ["x"]} ', {"0": "Hola"}), + ('{"0": "con \\"comillas\\" y \\u00f1"}', {"0": 'con "comillas" y ñ'}), + ("[]", {}), + ('"solo texto"', {}), + ("", {}), + ("no es json", {}), + ('{"0": "cortado', {}), + ], +) +def test_interpretar_respuestas_tipicas_y_rotas(crudo: str, esperado: dict[str, str]) -> None: + assert interpretar(crudo) == esperado + + +# ------------------------------------------------------------------ motor compatible +@pytest.mark.parametrize( + "cuerpo", + [ # pyright: ignore[reportUnknownArgumentType] + {}, + {"choices": []}, + {"choices": [{}]}, + {"choices": [{"message": None}]}, + {"choices": [{"message": {"content": None}}]}, + {"choices": [{"message": {"content": ["no", "texto"]}}]}, + {"choices": [{"message": {"content": 5}}]}, + {"choices": "no es lista"}, + [1, 2, 3], + "texto plano", + ], +) +def test_respuesta_con_forma_inesperada_no_rompe(armar: Callable[..., Any], cuerpo: object) -> None: + m, _, _ = armar(httpx.Response(200, json=cuerpo)) + assert m.traducir_lote(lote("Hello there")) == {} + + +def test_respuesta_que_no_es_json(armar: Callable[..., Any]) -> None: + m, _, _ = armar(httpx.Response(200, content=b"Bad gateway")) + assert m.traducir_lote(lote("Hello there")) == {} + + +def test_error_de_servidor_persistente_devuelve_vacio_tras_reintentar(armar: Callable[..., Any]) -> None: + m, pedidos, esperas = armar(httpx.Response(503, text="caído"), reintentos=3) + assert m.traducir_lote(lote("Hello there")) == {} + assert len(pedidos) == 3 and len(esperas) == 2 # esperó entre intentos, no tras el último + + +@pytest.mark.parametrize( + "excepcion", [httpx.ReadTimeout("lento"), httpx.RemoteProtocolError("cortado"), httpx.WriteError("x")] +) +def test_fallos_de_red_se_reintentan_y_no_revientan(armar: Callable[..., Any], excepcion: Exception) -> None: + m, pedidos, _ = armar(excepcion, reintentos=2) + assert m.traducir_lote(lote("Hello there")) == {} + assert len(pedidos) == 2 + + +def test_retry_after_basura_no_rompe(armar: Callable[..., Any]) -> None: + ok = httpx.Response(200, json={"choices": [{"message": {"content": json.dumps({"0": "Hola"})}}]}) + m, _, _ = armar(httpx.Response(429, headers={"retry-after": "soon"}), ok, reintentos=3) + assert m.traducir_lote(lote("Hello there")) == {"0": "Hola"} + + +def test_cancelar_corta_antes_de_pedir(armar: Callable[..., Any]) -> None: + m, pedidos, _ = armar(httpx.Response(200, json={})) + m.cancelar = threading.Event() + m.cancelar.set() + with pytest.raises(Cancelado): + m.traducir_lote(lote("Hello there")) + assert pedidos == [] + + +@pytest.mark.parametrize(("codigo", "texto"), [(401, "clave"), (403, "clave"), (402, "saldo"), (404, "modelo")]) +def test_errores_fatales_dicen_que_hacer(armar: Callable[..., Any], codigo: int, texto: str) -> None: + m, pedidos, _ = armar(httpx.Response(codigo, json={"error": {"message": "x"}})) + with pytest.raises(ErrorFatal, match=texto): + m.traducir_lote(lote("Hello there")) + assert len(pedidos) == 1 # no se insiste cuando reintentar no sirve + + +def test_el_pedido_lleva_el_texto_con_utf8_intacto(armar: Callable[..., Any]) -> None: + m, pedidos, _ = armar(httpx.Response(200, json={"choices": [{"message": {"content": "{}"}}]})) + m.traducir_lote(lote("Ñandú ¡sí! 日本語 😀 ⟦0⟧")) + cuerpo = json.loads(pedidos[0].content.decode("utf-8")) + assert "Ñandú ¡sí! 日本語 😀 ⟦0⟧" in cuerpo["messages"][1]["content"] + + +# ------------------------------------------------------------------ instrucciones, simulador, fábrica +def test_instrucciones_para_cada_ambito_y_tarea() -> None: + for ambito in ("ui", "mision", "libro", "script", "desconocido"): + for tarea in ("traducir", "revisar"): + lt = Lote( + (Entrada("0", "Hello ⟦0⟧", "p", "Hola ⟦0⟧"),), + ambito, + "español mexicano", + {"Iron": "Hierro"}, + "granja", + tarea, + ) + assert "español mexicano" in sistema(lt) or tarea == "revisar" + assert "Hello ⟦0⟧" in usuario(lt) + + +def test_simulador_conserva_marcadores_y_bordes() -> None: + r = Simulador().traducir_lote(lote("⟦0⟧ Hello ⟦1⟧")) + assert r == {"0": "«ES» ⟦0⟧ Hello ⟦1⟧"} or "⟦0⟧" in r["0"] + + +@pytest.mark.parametrize("motor", list(fabrica.MOTORES)) +def test_fabrica_pide_lo_que_falta(motor: str) -> None: + if fabrica.necesita_clave(motor): + with pytest.raises(ErrorFatal, match="Falta la clave"): + fabrica.crear(motor, None, "modelo") + elif motor == "personalizado": + with pytest.raises(ErrorFatal, match="dirección"): + fabrica.crear(motor, None, "modelo") + else: + m = fabrica.crear(motor, None, "modelo") + assert m.nombre + cerrar = getattr(m, "cerrar", None) + if callable(cerrar): + cerrar() + + +def test_fabrica_motor_desconocido() -> None: + with pytest.raises(ErrorFatal, match="desconocido"): + fabrica.crear("no-existe", "clave", "modelo") diff --git a/tests/test_verificacion_nucleo.py b/tests/test_verificacion_nucleo.py new file mode 100644 index 0000000..1864c01 --- /dev/null +++ b/tests/test_verificacion_nucleo.py @@ -0,0 +1,191 @@ +"""Casos límite del núcleo: memoria, glosario y traductor con entradas raras, motores que se portan mal y UTF-8.""" + +import sqlite3 +import threading +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from traductor_es.motores.base import ErrorFatal, Lote +from traductor_es.motores.simulador import Simulador +from traductor_es.nucleo.glosario import Glosario +from traductor_es.nucleo.mascara import desenmascarar, enmascarar, es_traducible, separar_bordes +from traductor_es.nucleo.memoria import Memoria +from traductor_es.nucleo.traductor import Traductor + +ESPECIALES = "Ñandú sí, ¿cómo estás? ¡Ágüita! 日本語 😀 §a%s §r%d {0} ${x}" + + +@pytest.fixture +def memoria() -> Iterator[Memoria]: + with Memoria(":memory:") as m: + yield m + + +# ------------------------------------------------------------------ memoria +def test_memoria_guarda_y_lee_utf8_en_disco(tmp_path: Path) -> None: + ruta = tmp_path / "datos" / "memoria.db" + with Memoria(ruta) as m: + m.guardar({ESPECIALES: "Ñandú ¡sí! §a%s"}, "es_mx", "ui", "prueba") + assert m.obtener(ESPECIALES, "es_mx", "ui") == "Ñandú ¡sí! §a%s" + with Memoria(ruta) as m: # se vuelve a abrir: persiste y sigue siendo UTF-8 + assert m.obtener(ESPECIALES, "es_mx", "ui") == "Ñandú ¡sí! §a%s" + assert m.cantidad() == 1 + + +def test_memoria_buscar_no_trata_comodines_como_comodines(memoria: Memoria) -> None: + memoria.guardar({"100% real": "100 % real", "abc": "abc", "a_c": "a_c", "a!c": "a!c"}, "es_mx", "ui", "p") + assert [c.origen for c in memoria.buscar("%", "es_mx")] == ["100% real"] + assert [c.origen for c in memoria.buscar("a_c", "es_mx")] == ["a_c"] + assert [c.origen for c in memoria.buscar("a!c", "es_mx")] == ["a!c"] + + +def test_memoria_con_inyeccion_sql_en_el_texto(memoria: Memoria) -> None: + raro = "'; DROP TABLE traducciones; --" + memoria.guardar({raro: "x"}, "es_mx", "ui", "p") + assert memoria.obtener(raro, "es_mx", "ui") == "x" and memoria.cantidad() == 1 + assert memoria.buscar(raro, "es_mx")[0].origen == raro + + +def test_memoria_desde_varios_hilos(memoria: Memoria) -> None: + errores: list[BaseException] = [] + + def trabajar(n: int) -> None: + try: + for i in range(40): + memoria.guardar({f"texto {n}-{i}": f"texto {n}-{i} ñ"}, "es_mx", "ui", "p") + assert memoria.obtener(f"texto {n}-{i}", "es_mx", "ui") == f"texto {n}-{i} ñ" + except BaseException as e: + errores.append(e) + + hilos = [threading.Thread(target=trabajar, args=(n,)) for n in range(8)] + for h in hilos: + h.start() + for h in hilos: + h.join() + assert not errores and memoria.cantidad() == 8 * 40 + + +def test_correccion_manda_y_se_puede_quitar(memoria: Memoria) -> None: + memoria.guardar({"Hello": "Hola"}, "es_mx", "ui", "p") + memoria.corregir("Hello", "es_mx", "¡Buenas!") + assert memoria.correccion("Hello", "es_mx") == "¡Buenas!" and memoria.correcciones("es_mx") == {"Hello": "¡Buenas!"} + memoria.quitar_correccion("Hello", "es_mx") + assert memoria.correccion("Hello", "es_mx") is None + + +def test_memoria_corrupta_da_un_error_claro(tmp_path: Path) -> None: + ruta = tmp_path / "memoria.db" + ruta.write_bytes(b"esto no es una base de datos sqlite" * 50) + with pytest.raises(sqlite3.DatabaseError): + Memoria(ruta) + + +# ------------------------------------------------------------------ glosario +@pytest.mark.parametrize( + "termino", ["C++", "(Test)", "a.b", "Tom's [Storage]", "50% off", "back\\slash", "$HOME", "x|y"] +) +def test_glosario_con_caracteres_de_regex(termino: str) -> None: + g = Glosario() + g.agregar_pares({"k": termino}, {"k": "traducido"}) + g.fijar({termino + " extra": "fijo"}) + assert isinstance(g.relevantes([f"use {termino} here and {termino} extra"]), dict) + + +def test_glosario_respeta_palabras_completas() -> None: + g = Glosario() + g.agregar_pares({"a": "Wheat", "b": "Iron Ingot"}, {"a": "Trigo", "b": "Lingote de hierro"}) + assert g.relevantes(["Wheat bread", "Iron Ingot"]) == {"Wheat": "Trigo", "Iron Ingot": "Lingote de hierro"} + assert g.relevantes(["Wheatgrass", "Ironwork"]) == {} # no son palabras completas + assert g.relevantes([]) == {} + + +# ------------------------------------------------------------------ máscara y UTF-8 +def test_mascara_ida_y_vuelta_con_especiales() -> None: + m = enmascarar(ESPECIALES) + assert desenmascarar(m.texto, m.tokens) == ESPECIALES + assert "Ñ" in m.texto and "😀" in m.texto and "日本語" in m.texto # lo que no es código se queda legible + + +@pytest.mark.parametrize("texto", ["", " ", "\n", "§a", "%s", "{0}", "
", "https://x.y/z", "12345", "---"]) +def test_textos_sin_nada_que_traducir(texto: str) -> None: + assert not es_traducible(texto) + assert separar_bordes(texto)[1] in ("", texto.strip()) + + +# ------------------------------------------------------------------ traductor con motores que se portan mal +class _Motor: + nombre = "malo" + + def __init__(self, respuesta: object) -> None: + self.respuesta = respuesta + self.llamadas = 0 + + def traducir_lote(self, lote: Lote) -> dict[str, str]: + self.llamadas += 1 + r = self.respuesta + if callable(r): + return r(lote) # type: ignore[no-any-return] + return r # type: ignore[return-value] + + +def test_traductor_con_lista_vacia_y_duplicados(memoria: Memoria) -> None: + t = Traductor(Simulador(), memoria, Glosario()) + assert t.traducir([], "ui").traducciones == {} + r = t.traducir(["Hello world", "Hello world", " Hello world "], "ui") + assert r.traducciones["Hello world"] == "«ES» Hello world" + assert r.traducciones[" Hello world "] == " «ES» Hello world " # los bordes se conservan + + +def test_traductor_con_codigos_y_especiales(memoria: Memoria) -> None: + t = Traductor(Simulador(), memoria, Glosario()) + textos = ["§aYou have %d items in %s", "Press {0} to open the menu ¡ya!", "Ñandú ${nombre} says §chello there"] + r = t.traducir(textos, "ui") + for original in textos: + assert enmascarar(original).tokens == enmascarar(r.traducciones[original]).tokens # mismos códigos + assert "«ES»" in r.traducciones[original] + + +def test_motor_que_pierde_los_codigos_se_descarta(memoria: Memoria) -> None: + def sin_codigos(lote: Lote) -> dict[str, str]: + return {e.id: "Texto sin marcadores" for e in lote.entradas} + + motor = _Motor(sin_codigos) + r = Traductor(motor, memoria, Glosario()).traducir(["§aHello %s there"], "ui") + assert "§aHello %s there" not in r.traducciones or r.traducciones["§aHello %s there"] == "§aHello %s there" + assert motor.llamadas >= 1 # reintentó y no aceptó la traducción rota + + +@pytest.mark.parametrize("respuesta", [{}, {"id-que-no-existe": "x"}, {"0": ""}]) +def test_motor_que_omite_o_inventa_ids(memoria: Memoria, respuesta: dict[str, str]) -> None: + r = Traductor(_Motor(respuesta), memoria, Glosario()).traducir(["Hello brave world"], "ui") + assert r.traducciones.get("Hello brave world", "Hello brave world") == "Hello brave world" # queda en inglés + + +def test_error_fatal_del_motor_se_propaga(memoria: Memoria) -> None: + def falla(_lote: Lote) -> dict[str, str]: + raise ErrorFatal("clave inválida") + + with pytest.raises(ErrorFatal): + Traductor(_Motor(falla), memoria, Glosario()).traducir(["Hello brave world"], "ui") + + +def test_texto_muy_largo_se_traduce_sin_perder_nada(memoria: Memoria) -> None: + largo = " ".join(f"Palabra{i}" for i in range(4000)) + r = Traductor(Simulador(), memoria, Glosario()).traducir([largo], "libro") + assert r.traducciones[largo] == "«ES» " + largo + + +def test_memoria_danada_da_un_error_claro_en_el_proceso(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from traductor_es.proceso import Proceso + + from . import fabrica + + datos = tmp_path / "datos" + datos.mkdir() + (datos / "memoria.db").write_bytes(b"basura" * 100) + monkeypatch.setenv("TRADUCTOR_ES_DATOS", str(datos)) + juego = fabrica.prism(tmp_path, "1.20.1") + with pytest.raises(ErrorFatal, match="memoria de traducciones está dañada"): + Proceso(juego, Simulador()) diff --git a/tests/test_verificacion_salida.py b/tests/test_verificacion_salida.py new file mode 100644 index 0000000..c90b919 --- /dev/null +++ b/tests/test_verificacion_salida.py @@ -0,0 +1,185 @@ +"""Casos límite de la salida: options.txt, copias de seguridad, exportación y la línea de comandos real.""" + +import json +import os +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest + +from traductor_es.salida import exportar, opciones +from traductor_es.salida.copias import Respaldo + +from . import fabrica + +RAIZ = Path(__file__).resolve().parent.parent + + +# ------------------------------------------------------------------ options.txt +def _opts(tmp_path: Path, crudo: bytes) -> Path: + (tmp_path / "options.txt").write_bytes(crudo) + return tmp_path + + +def test_activar_conserva_crlf_y_otras_lineas_y_es_idempotente(tmp_path: Path) -> None: + crudo = ( + 'fov:0.5\r\nlang:en_us\r\nresourcePacks:["vanilla","file/Otro"]\r\n' + 'incompatibleResourcePacks:["file/Traduccion_ES"]\r\n' + ) + _opts(tmp_path, crudo.encode()) + cambios = opciones.activar(tmp_path, "Traduccion_ES", "es_mx") + nuevo = (tmp_path / "options.txt").read_bytes().decode() + assert cambios and nuevo.count("\r\n") == nuevo.count("\n") and "fov:0.5\r\n" in nuevo + assert 'resourcePacks:["vanilla","file/Otro","file/Traduccion_ES"]' in nuevo and "lang:es_mx" in nuevo + assert "incompatibleResourcePacks:[]" in nuevo + assert opciones.activar(tmp_path, "Traduccion_ES", "es_mx") == [] # ya está: no cambia nada + assert (tmp_path / "options.txt").read_bytes().decode() == nuevo + + +def test_activar_sin_options_no_crea_nada_y_con_lista_danada_no_toca(tmp_path: Path) -> None: + assert opciones.activar(tmp_path, "Traduccion_ES", "es_mx") == [] and not (tmp_path / "options.txt").exists() + crudo = b"lang:en_us\nresourcePacks:[esto no es json\n" + _opts(tmp_path, crudo) + with pytest.raises(ValueError, match="lista de resource packs"): + opciones.activar(tmp_path, "Traduccion_ES", "es_mx") + assert (tmp_path / "options.txt").read_bytes() == crudo + + +def test_activar_conserva_bytes_que_no_son_utf8_y_nombres_con_ene(tmp_path: Path) -> None: + crudo = b'lastServer:caf\xe9\nresourcePacks:["vanilla","file/\xc3\x91and\xc3\xba"]\n' + _opts(tmp_path, crudo) + opciones.activar(tmp_path, "Traduccion_ES", None) + nuevo = (tmp_path / "options.txt").read_bytes() + assert b"lastServer:caf\xe9\n" in nuevo # el byte raro sigue igual + assert '"file/Ñandú","file/Traduccion_ES"'.encode() in nuevo + + +# ------------------------------------------------------------------ copias de seguridad +def test_respaldo_restaura_cuando_el_archivo_cambio_o_se_borro(tmp_path: Path) -> None: + juego = tmp_path / "juego" + ruta = juego / "kubejs" / "ñandú ¡x!.js" + ruta.parent.mkdir(parents=True) + ruta.write_bytes(b"original") + r = Respaldo(juego) + r.escribir(ruta, b"traducido") + assert r.original(ruta) == b"original" + ruta.unlink() # el usuario borró el archivo + assert r.restaurar() == [ruta] and ruta.read_bytes() == b"original" + ruta.write_bytes(b"actualizado por el modpack") # y luego lo actualizó otra versión del modpack + assert r.original(ruta) == b"actualizado por el modpack" + + +def test_respaldo_con_registro_corrupto_empieza_de_cero(tmp_path: Path) -> None: + juego = tmp_path / "juego" + (juego / "traductor_es").mkdir(parents=True) + (juego / "traductor_es" / "registro.json").write_bytes(b"{no es json") + ruta = juego / "a.js" + ruta.write_bytes(b"x") + r = Respaldo(juego) + r.escribir(ruta, b"y") + assert json.loads((juego / "traductor_es" / "registro.json").read_text("utf-8")) + + +def test_respaldo_rechaza_rutas_fuera_de_la_instancia(tmp_path: Path) -> None: + juego = tmp_path / "juego" + juego.mkdir() + fuera = tmp_path / "fuera.txt" + fuera.write_bytes(b"x") + with pytest.raises(ValueError): + Respaldo(juego).escribir(fuera, b"y") + assert fuera.read_bytes() == b"x" + + +# ------------------------------------------------------------------ exportar +def test_exportar_kit_y_pack(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + pack = juego / "resourcepacks" / "Traduccion_ES" + (pack / "assets" / "m" / "lang").mkdir(parents=True) + (pack / "pack.mcmeta").write_text("{}", encoding="utf-8") + (pack / "assets" / "m" / "lang" / "es_mx.json").write_text('{"a": "Ñandú"}', encoding="utf-8") + script = juego / "kubejs" / "server_scripts" / "s.js" + script.parent.mkdir(parents=True) + script.write_text("x", encoding="utf-8") + Respaldo(juego).escribir(script, b"traducido") + destino = tmp_path / "salida" / "kit.zip" + assert exportar.exportar_kit_servidor(juego, destino) >= 2 + with zipfile.ZipFile(destino) as z: + assert z.testzip() is None and "kubejs/server_scripts/s.js" in z.namelist() + assert exportar.exportar_pack(juego, tmp_path / "salida" / "pack.zip") == 2 + with pytest.raises(ValueError, match="traduce primero"): + exportar.exportar_pack(tmp_path / "otro", tmp_path / "x.zip") + + +def test_el_kit_incluye_datapacks_comprimidos_con_datos_del_servidor(tmp_path: Path) -> None: + import io + + def comprimido(entradas: dict[str, str]) -> bytes: + salida = io.BytesIO() + with zipfile.ZipFile(salida, "w") as z: + for nombre, texto in entradas.items(): + z.writestr(nombre, texto) + return salida.getvalue() + + juego = fabrica.prism(tmp_path, "1.20.1") + con_logros = juego / "global_packs" / "required_data" / "logros.zip" + solo_tips = juego / "global_packs" / "required_data" / "tips.zip" + for ruta in (con_logros, solo_tips): + ruta.parent.mkdir(parents=True, exist_ok=True) + datos_logros = comprimido({"data/m/advancements/a.json": '{"display": {"title": {"text": "Hola"}}}'}) + datos_tips = comprimido({"assets/m/tips/t.json": '{"tip": "Un consejo"}'}) + con_logros.write_bytes(datos_logros) + solo_tips.write_bytes(datos_tips) + respaldo = Respaldo(juego) + respaldo.escribir(con_logros, datos_logros) + respaldo.escribir(solo_tips, datos_tips) + destino = tmp_path / "kit.zip" + exportar.exportar_kit_servidor(juego, destino) + with zipfile.ZipFile(destino) as z: + nombres = set(z.namelist()) + assert "global_packs/required_data/logros.zip" in nombres # lo lee el servidor + assert "global_packs/required_data/tips.zip" not in nombres # los tips son de cliente + + +# ------------------------------------------------------------------ línea de comandos real +def _cli(*args: str, datos: Path) -> subprocess.CompletedProcess[str]: + entorno = {**os.environ, "TRADUCTOR_ES_DATOS": str(datos), "PYTHONIOENCODING": "utf-8"} + return subprocess.run( + [sys.executable, "-m", "traductor_es", *args], capture_output=True, text=True, encoding="utf-8", + env=entorno, cwd=RAIZ, timeout=120, check=False, + ) # fmt: skip + + +def test_cli_ayuda_version_y_argumentos_invalidos(tmp_path: Path) -> None: + assert _cli("--version", datos=tmp_path).returncode == 0 + assert "traducir" in _cli("--help", datos=tmp_path).stdout + malo = _cli("traducir", datos=tmp_path) # falta la carpeta + assert malo.returncode != 0 and "carpeta" in malo.stderr + assert _cli("analizar", "x", "--dialecto", "klingon", datos=tmp_path).returncode != 0 + + +def test_cli_carpeta_que_no_existe_da_un_error_claro_sin_traza(tmp_path: Path) -> None: + r = _cli("analizar", str(tmp_path / "no_existe"), datos=tmp_path) + assert r.returncode != 0 and "Traceback" not in r.stderr + r.stdout + + +def test_cli_analiza_traduce_simulado_restaura_y_cobertura(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + script = juego / "kubejs" / "server_scripts" / "s.js" + script.parent.mkdir(parents=True) + original = "PlayerEvents.chat(e => { e.player.tell('Welcome to the pack ñ') })\n" + script.write_text(original, encoding="utf-8") + fabrica.jar(juego / "mods" / "m.jar", fabrica.lang("m", "en_us", {"item.m.a": "Iron Plate ñ"})) + datos = tmp_path / "datos" + carpeta = str(juego.parent) + a = _cli("analizar", carpeta, datos=datos) + assert a.returncode == 0 and "Traceback" not in a.stderr and script.read_text("utf-8") == original # solo lee + t = _cli("traducir", carpeta, "--simular", "--no-activar", "--no-idioma", datos=datos) + assert t.returncode == 0, t.stderr + assert "«ES» Welcome to the pack ñ" in script.read_text("utf-8") + assert json.loads((juego / "resourcepacks" / "Traduccion_ES" / "assets" / "m" / "lang" / "es_mx.json").read_bytes()) + c = _cli("cobertura", carpeta, datos=datos) + assert c.returncode == 0 and "Traceback" not in c.stderr + r = _cli("restaurar", carpeta, datos=datos) + assert r.returncode == 0 and script.read_text("utf-8") == original diff --git a/tests/test_verificacion_validacion.py b/tests/test_verificacion_validacion.py new file mode 100644 index 0000000..dc403d5 --- /dev/null +++ b/tests/test_verificacion_validacion.py @@ -0,0 +1,87 @@ +"""Casos límite de las reglas de calidad y de la revisión automática.""" + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from traductor_es.validacion.calidad import revisar_glosario, validar +from traductor_es.validacion.revision import ResumenRevision, interpretar_veredicto + +SUAVE = settings(max_examples=200, suppress_health_check=[HealthCheck.too_slow], deadline=None) + + +@pytest.mark.parametrize( + ("original", "traduccion", "motivo"), + [ + ("Iron Plate", "Placa de hierro", None), + ("Iron Plate", "", "vacía"), + ("Iron Plate", " \t ", "vacía"), + ("One line", "Dos\nlíneas", "salto de línea nuevo"), + ("One\nline", "Una\nlínea", None), + ("A long enough original text here", "x", "largo raro"), + ("Short", "Un texto absurdamente largo para algo tan corto, de verdad que sí lo es", None), + ( + "You can use the tool to break the stone and then place it with your hand", + "You can use the tool to break the stone and then place it with your hand", + "sin traducir", + ), + ("⟦0⟧ and ⟦1⟧", "⟦0⟧ y ⟦1⟧", None), + ("Ñandú ¡Ágüita! cañón", "Ñandú ¡Ágüita! cañón", None), # nombres propios iguales: no es error + ("日本語のテキスト", "日本語のテキスト", None), + ], +) +def test_validar_casos(original: str, traduccion: str, motivo: str | None) -> None: + assert validar(original, traduccion) == motivo + + +@SUAVE +@given(st.text(max_size=200), st.text(max_size=200)) +def test_validar_nunca_falla(original: str, traduccion: str) -> None: + resultado = validar(original, traduccion) + assert resultado is None or isinstance(resultado, str) + + +def test_revisar_glosario_con_regex_y_utf8() -> None: + glosario = {"C++": "C++", "(Test)": "(Prueba)", "Wheat": "Trigo", "Ñu": "Ñu"} + traducciones = { + "Use C++ here": "Usa C++ aquí", + "A (Test) item": "Un objeto", + "Wheat bread": "Pan de cebada", + "Ñu is Ñu": "Ñu es Ñu", + } + r = revisar_glosario(traducciones, glosario, list(glosario)) + assert r.inconsistentes == {"(Test)": 1, "Wheat": 1} + assert "Términos del glosario no respetados" in str(r) + assert str(revisar_glosario({}, glosario, [])) == "Glosario respetado en todas las traducciones." + + +@pytest.mark.parametrize( + ("valor", "esperado"), + [ + ("OK", None), + ("ok, todo bien", None), + ("", None), + ("grave|exactitud|cambia el sentido", ("grave", "exactitud", "cambia el sentido")), + ("Crítica | Terminología | falta el glosario", ("critica", "terminologia", "falta el glosario")), + ("grave|exactitud", None), # falta la explicación + ("enorme|exactitud|x", None), # gravedad desconocida + ("grave|ortografia|x", None), # categoría desconocida + ("grave|estilo|" + "x" * 1000, ("grave", "estilo", "x" * 300)), # se recorta + ("grave|estilo|con | barras | extra", ("grave", "estilo", "con | barras | extra")), + ], +) +def test_veredicto(valor: str, esperado: tuple[str, str, str] | None) -> None: + assert interpretar_veredicto(valor) == esperado + + +@SUAVE +@given(st.text(max_size=100)) +def test_veredicto_nunca_falla(valor: str) -> None: + interpretar_veredicto(valor) + + +def test_resumen_de_revision_suma_y_se_lee() -> None: + a = ResumenRevision(revisados=3, marcados={"exactitud": 1}, leves=1, corregidos=1) + a.sumar(ResumenRevision(revisados=2, marcados={"exactitud": 1, "estilo": 2}, leves=0, corregidos=2)) + assert a.revisados == 5 and a.marcados == {"exactitud": 2, "estilo": 2} and a.corregidos == 3 + assert "4 con errores graves" in str(a) and "ninguno con errores graves" in str(ResumenRevision(revisados=7))