diff --git a/.gitignore b/.gitignore
index 480975b75..d5f81bc9b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -198,6 +198,7 @@ _pkginfo.txt
# Others
ClientBin/
~$*
+deploy/all-in-one/docker-compose.public-ip.yml
*~
*.dbmdl
*.dbproj.schemaview
@@ -316,3 +317,7 @@ src/AdminPanel/wwwroot/content/js/app.js.map
# Mac OS filesystem files
.DS_Store
+
+# Local MU client sources (do not commit)
+/_MuMain/
+/MuMain/
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 000000000..0b6deab44
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "chatgpt.config": {}
+}
\ No newline at end of file
diff --git a/QuickStart.md b/QuickStart.md
index 2ff4680e7..c4d072407 100644
--- a/QuickStart.md
+++ b/QuickStart.md
@@ -1,4 +1,9 @@
-# Quick Start OpenMU
+# Quick Start OpenMU / Guía Rápida OpenMU
+
+*Read this QuickStart in [English](#english) or [Español](#espanol).*
+
+
+## English
General requirements:
@@ -143,3 +148,125 @@ Season 6 only:
* socket: Test account with socket item sets, level 380 characters
The __passwords__ of these accounts are the __same as the user name__.
+
+
+## Español
+
+Requisitos generales:
+
+* Puertos TCP libres:
+ * 80 (admin panel)
+ * 55901 - 55906 (game servers)
+ * 44405 - 44406 (connect servers)
+ * 44405: default connection port for the original client
+ * 44406: connection port especially for the [open source client](https://github.com/sven-n/MuMain)
+ * 55980 (chat server)
+
+* Un game client (revisa las FAQs de [nuestro Discord](https://discord.gg/2u5Agkd))
+* Conocimiento o forma de iniciar el game client para que se conecte al server. Nuestro Launcher lo hará.
+
+ * Binarios del Launcher: [MUnique.OpenMU.ClientLauncher v0.9.6.zip](https://github.com/MUnique/OpenMU/releases/download/v0.9.0/MUnique.OpenMU.ClientLauncher_0.9.6.zip)
+ * Requiere el [.NET 9 runtime](https://dotnet.microsoft.com/download/dotnet/9.0)
+ * Si tu server y client corren en tu host local, usa cualquier IP de 127.x.x.x, excepto 127.0.0.1, porque el client la bloquea. Por ejemplo, podrías usar 127.127.127.127
+
+Esta guía describe dos maneras de iniciar el server. Usa Docker si solo quieres probar. Si quieres desarrollar o depurar el server, elige la forma manual.
+
+Como puedes ver en los puertos del connect server, el server se inicializa para dos clients diferentes por defecto.
+Pueden conectarse a los mismos game servers a través de distintos puertos. Sin embargo, si te conectas al puerto incorrecto,
+quizás actualmente todavía funcione correctamente y solo obtendrás advertencias en los logs. Sin embargo, tan pronto como
+cambiemos las claves o métodos de encriptación, esto cambiará.
+
+## Docker
+
+Por favor, revisa la carpeta deploy de este proyecto. Allí encontrarás una guía más detallada sobre cómo configurar este proyecto.
+
+### Variables de entorno
+
+Es posible definir variables de entorno adicionales para influir en las cadenas de conexión de la base de datos postgres.
+
+| Nombre | Descripción |
+|-------|-------------|
+| DB_HOST | El hostname de la base de datos. Si el archivo de configuración local aún está configurado para usar 'localhost', el valor de esta variable lo reemplaza |
+| DB_ADMIN_USER | El nombre de usuario de la cuenta admin de postgres. Si el archivo de configuración local aún está configurado para usar 'postgres' como nombre de usuario del admin (primer entrada en ConnectionSettings.xml), el valor de esta variable lo reemplaza. |
+| DB_ADMIN_PW | La contraseña de la cuenta admin de postgres. Si el archivo de configuración local aún está configurado para usar 'admin' para la contraseña del admin (primer entrada en ConnectionSettings.xml), el valor de esta variable lo reemplaza. |
+
+## Manualmente
+
+Usa este método si quieres desarrollar o depurar OpenMU.
+
+Requisitos:
+
+* Windows OS, 10 o superior
+
+ * También se ejecuta en Linux y MacOS. Sin embargo, esta guía lo describe para Windows.
+
+ * PostgreSQL instalado
+
+ * Visual Studio 2022 (17.12+) instalado, con workloads para ASP.NET Web development y .NET Desktop development. Manténlo actualizado para evitar problemas.
+
+ * Extensión de Visual Studio "Web Compiler 2022+" si planeas editar archivos SCSS para el admin panel.
+ * https://marketplace.visualstudio.com/items?itemName=Failwyn.WebCompiler64
+
+ * [.NET SDK 9](https://dotnet.microsoft.com/download/dotnet/9.0) (debería estar incluido en Visual Studio 17.12+)
+ * `winget install Microsoft.DotNet.SDK.9`
+
+ * [NodeJS 16+](https://nodejs.org) instalado
+ * `winget install OpenJS.NodeJS.LTS`
+
+ * Este repositorio clonado
+
+Si tienes eso, tendrás que:
+
+* Abrir la solución de OpenMU con Visual Studio
+
+* Click derecho en la solución y 'Restore NuGet Packages'
+
+* Editar OpenMU\Persistence\EntityFramework\ConnectionSettings.xml, para que las cadenas de conexión sean correctas; sin embargo, solo el usuario/contraseña de la primera y segunda cadena necesitan ser correctos. El server intentará crear los otros roles especificados por la configuración.
+
+* Build la solución
+
+* Iniciar MUnique.OpenMU.Startup
+
+ * Si es necesario, creará los esquemas de base de datos, los roles requeridos y dará permisos a estos roles.
+
+ * Opcional: puedes reinicializar la base de datos agregando un parámetro ```-reinit```.
+
+* Cuando el Admin Panel esté inicializado, ve a . Allí deberías ver tres game servers, el chat server y dos connect servers. Inicia los connect servers y al menos un game server.
+
+* Si actualizas a un estado más reciente de la rama master, podría ser necesario actualizar la base de datos y la configuración. Puedes encontrar actualizaciones en el admin panel.
+
+* Luego puedes conectarte al server a través del game client.
+
+## Pasos útiles (opcionales)
+
+* __Auto Start__: Si no quieres iniciar cada server listener después de iniciar el proceso, puedes activar "Auto Start"
+
+ * en el admin panel en ```Configuration -> System```,
+
+ * o con el parámetro de inicio ```-autostart```.
+
+* __Resolución de IP__: Si encuentras desconexiones después de seleccionar un server, lo más probable es una configuración incorrecta para el IP resolver. Puedes cambiarlo fácilmente desde el admin panel en ```Configuration -> System```. También puedes cambiar el ajuste proporcionando start parameters o environment parameters; sin embargo, solo lo recomiendo para usuarios experimentados. Mira este [Readme](src/Startup/Readme.md) para más información.
+
+* __Cambiar la versión del juego__: Si quieres jugar otra versión distinta de la season 6, puedes inicializar la base de datos con otra versión del juego. Puedes hacerlo también desde el admin panel en la página ```Setup```.
+
+## Cuentas de prueba
+
+Para probar algunas features del server, se crean cuentas de prueba automáticamente cuando se inicializa la base de datos.
+
+Estos son los user names:
+
+* test0 - test9: General test accounts, level 1 a 90, en pasos de 10 niveles
+
+Solo Season 6:
+* test300: General test account con level 300
+* test400: General test account con level 400, master characters
+* testgm: Test account de un game master
+* testgm2: Test account de un game master con personajes summoner y rage fighter
+* testunlock: Test account sin characters, pero classes desbloqueadas
+* quest1: Test account para las level 150 quests
+* quest2: Test account para las level 220 quests
+* quest3: Test account para las level 400 quests
+* ancient: Test account con ancient item sets, level 330 characters
+* socket: Test account con socket item sets, level 380 characters
+
+Los __passwords__ de estas cuentas son __los mismos que el user name__.
diff --git a/README.bak b/README.bak
new file mode 100644
index 000000000..7d0787b92
--- /dev/null
+++ b/README.bak
@@ -0,0 +1,314 @@
+# OpenMU Project / Proyecto OpenMU
+
+[](LICENSE)
+[](https://www.codacy.com/gh/MUnique/OpenMU/dashboard?utm_source=github.com&utm_medium=referral&utm_content=MUnique/OpenMU&utm_campaign=Badge_Grade)
+[](https://gitter.im/OpenMU-Project/Lobby)
+[](https://discord.gg/2u5Agkd)
+
+*Read this README in [English](#english) or [Español](#espanol).*
+
+
+## English
+
+| Platform |Build Status |
+|----------------|----------------------|
+| Windows |  |
+| Linux (Docker) | [](https://hub.docker.com/r/munique/openmu) |
+
+| NuGet Packages | |
+|----------------|---|
+| MUnique.OpenMU.Network | [](https://www.nuget.org/packages/MUnique.OpenMU.Network/) |
+| MUnique.OpenMU.Network.Packets | [](https://www.nuget.org/packages/MUnique.OpenMU.Network.Packets/) |
+
+This project aims to create an easy to use, extendable and customizable server
+for a MMORPG called "MU Online".
+The server supports multiple versions of the game, but the main focus is
+version of Season 6 Episode 3 using the ENG (english) protocol. Additionally,
+the long-term focus is on the [open source client](https://github.com/sven-n/MuMain)
+which supports a slightly extended network protocol.
+However, parts of the software can also be suitable for the development of
+other games, even for other kind of games.
+
+The code is a complete rewrite from scratch - it's not based on pre-existing
+projects, and it's also explicitly not based on decompiled server sources or
+their countless derivates.
+
+There also exists a [blog](https://munique.net) which may contain some valuable
+information about this development.
+
+## Fork changelog
+
+This fork diverges from the original OpenMU project and introduces:
+
+- Bilingual documentation in English and Spanish.
+- LAN presets for Season 6 with deployment overlays for LAN, DNS and npm.
+- `proxynet` integration replacing nginx and fixing port configuration.
+- Additional Spanish translations and admin panel language fixes.
+- New crafting recipes plus fixes for craftings, skills and grid issues.
+- Updated White Wizard event data and related fixes.
+
+### Elf Summon Plug-in
+
+This fork includes a configurable plug-in to change Elf summons (skills 30..36) and scale their stats by Energy without restarting the server.
+
+Code location: `src/GameLogic/PlugIns/ElfSummonsAll.cs`.
+
+What it provides
+- Replace the summoned monster per skill (30..36) or keep the default mapping.
+- Dynamic scaling by Energy applied to the base stats of the chosen monster (HP, base damage Phys/Wiz/Curse, DefenseBase):
+ `scale = 1 + floor(TotalEnergy / EnergyPerStep) * PercentPerStep`.
+- Buff/regeneration skills also include your own summon (and party members summons) when the target mode is self/party.
+- Apply configuration changes at runtime; just unsummon and summon again.
+
+How to enable
+- In the Admin Panel: Plugins ? filter by "Summon configuration".
+- You will see 7 entries: "Elf Summon cfg (30..36)". Activate the ones you need.
+- Edit the "Custom Configuration" of each. Available fields:
+ - `MonsterNumber` (int): 0 = use the server default mapping; >0 = monster number to summon.
+ - `EnergyPerStep` (int): 0 to disable; otherwise size of each Energy step (e.g. 1000).
+ - `PercentPerStep` (float): added per step (e.g. 0.05 = +5%).
+
+Important notes (for using this plug-in in another repo)
+- Monster stat cache adjustment (required so scaling applies to summons):
+ - In `src/GameLogic/Attributes/MonsterAttributeHolder.cs`, dont cache by `MonsterDefinition` (equals by Id). Summoned clones share Id; read attributes per-instance instead. Included in this fork.
+- Prevent damage to your own summon with area skills (recommended):
+ - In `src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs` and `src/GameLogic/PlayerActions/Skills/AreaSkillHitAction.cs`, exclude `Monster { SummonedBy == player }` from targets. Included in this fork.
+- Configuration hot-reload: On each summon creation, the plug-in fetches the latest CustomConfiguration from the database (no cache). No restart required; just re-summon.
+- Pet HUD (Fenrir/Raven bar): Elf summons dont use the item-pet system, so the stock client doesnt show that bar. Name/owner display is supported. Pet HUD would require client changes.
+
+Examples
+- +5% per 1000 Energy using default monster: `{"MonsterNumber": 0, "EnergyPerStep": 1000, "PercentPerStep": 0.05}`.## Current project state
+
+This project is currently under development without any release.
+You can try the current state by using the available docker image, also
+mentioned in the [quick start guide](QuickStart.md).
+
+## Licensing
+
+This project is released under the MIT license (see LICENSE file).
+
+## Used technologies
+
+The project is mainly written in C# and targets .NET 9.0.
+
+The servers admin panel is hosted on an embedded ASP.NET Core webserver (Kestrel)
+and implemented as Blazor Server App.
+
+At the moment the persistence layer uses the [Entity Framework Core](https://github.com/aspnet/EntityFrameworkCore)
+and [PostgreSQL](https://www.postgresql.org) as database. Additionally, it's
+also possible to start it in a non-persistent in-memory mode.
+
+The project supports distributed hosting based on Dapr. Alternatively, it can be
+hosted in one process as well.
+
+## Deployment
+
+We provide Docker images and docker-compose files for easy deployment.
+Please take a look at the deploy-folder of this project.
+
+## Contributions
+
+Contributions are welcome if they meet the following criteria:
+
+* Language is english.
+
+* Code should be StyleCop compliant - this project uses the [StyleCop.Analyzers](https://www.nuget.org/packages/StyleCop.Analyzers/)
+ for VS2022 so you should see issues directly as warnings.
+
+* Coding style (naming, etc.) and quality should fit to the current state.
+
+* No code copied/converted from the well-known decompiled source of the
+ original server.
+
+If you want to contribute, please create a new issue for the feature or bug (if
+the issue doesn't exist yet) so we can see who is working on something and can
+discuss possible solutions. If it's a small thing, you can also just send a
+pull request without adding an issue.
+
+Apart of that, contributions from non-developers are welcome as well. You can
+test the server, submit issues or suggestions, packet descriptions or
+documentations about the concepts and mechanics of the game itself. Please use
+markdown files/syntax for this purpose.
+
+If you have questions about that, don't hesitate to ask in our [discord channel](https://discord.gg/2u5Agkd)
+or by submitting an issue.
+
+## How to contribute code
+
+If you want to contribute code, please do the following steps:
+
+1. fork this project from the original MUnique OpenMU Project.
+2. create a feature branch from the master branch
+3. commit your changes to your feature branch
+4. submit a pull request to the original master branch
+5. lean back, wait for the code review and merge :)
+
+## How to use
+
+Please have a look at the [quick start guide](QuickStart.md).
+
+## Gameplay differences to the original server
+
+This project doesn't have the goal to copy the original MU Online server
+behavior to 100 %. This is not entirely possible, because the original server
+is written in another programming language and has a completely different
+architecture.
+With some points we make our life easier in this project, with other points we
+try to improve the gameplay.
+
+### Calculations
+
+The calculations of attribute values (like character damage decrement etc.) are
+done with 32 bit float numbers and without rounding off, like the original
+server does at some places.
+E.g. distributed stat points always have effect, while in the original server
+effects might get rounded down. For example, when 4 points of strength gives 1
+base damage, the original server doesn't calculate a fraction of 1 damage for
+3 points, while OpenMU calculates 0.75 damage. This damage
+has then an effect in further calculations.
+
+### Countdown when changing character or sub-server
+
+The original server uses a five second countdown when a player wants to change
+his character or the sub-server. Maybe this was done for some performance
+reasons, as the original server would then save the character/account data.
+We think that's really annoying and see no real value in that, so we don't use
+a countdown.
+
+
+## Español
+
+| Plataforma | Estado de compilación |
+|-----------------|-------------------------------|
+| Windows |  |
+| Linux (Docker) | [](https://hub.docker.com/r/munique/openmu) |
+
+| Paquetes NuGet | |
+|----------------|---|
+| MUnique.OpenMU.Network | [](https://www.nuget.org/packages/MUnique.OpenMU.Network/) |
+| MUnique.OpenMU.Network.Packets | [](https://www.nuget.org/packages/MUnique.OpenMU.Network.Packets/) |
+
+Este proyecto tiene como objetivo crear un servidor fácil de usar, ampliable y personalizable para un MMORPG llamado "MU Online".
+El servidor admite múltiples versiones del juego, pero el enfoque principal es la versión de la Season 6 Episode 3 utilizando el protocolo ENG (inglés). Además, el enfoque a largo plazo está en el [cliente de código abierto](https://github.com/sven-n/MuMain), que soporta un protocolo de red ligeramente ampliado.
+Sin embargo, partes del software también pueden ser adecuadas para el desarrollo de otros juegos, incluso de otro tipo.
+
+El código es una reescritura completa desde cero; no se basa en proyectos preexistentes ni en fuentes de servidor descompiladas ni en sus innumerables derivados.
+
+También existe un [blog](https://munique.net) que puede contener información valiosa sobre este desarrollo.
+
+## Cambios del fork
+
+Este fork se desvía del proyecto original OpenMU e introduce:
+
+- Documentación bilingüe en inglés y español.
+- Presets LAN para Season 6 con overlays de despliegue para LAN, DNS y npm.
+- Integración de `proxynet` reemplazando nginx y corrigiendo la configuración de puertos.
+- Traducciones adicionales al español y correcciones de idioma del panel de administración.
+- Nuevas recetas de crafteo y correcciones para crafteo, habilidades y problemas de cuadrícula.
+- Datos actualizados del evento White Wizard y correcciones relacionadas.
+
+### Plugin de invocaciones de Elfa
+
+Este fork incluye un plugin configurable para cambiar las invocaciones de la Elfa (skills 30..36) y escalar sus stats en base a la Energa, sin reiniciar el servidor.
+
+Ubicacion del codigo: `src/GameLogic/PlugIns/ElfSummonsAll.cs`.
+
+Que permite
+- Reemplazar el monstruo invocado por cada skill (30..36) o mantener el mapeo por defecto.
+- Escalado por Energia aplicado a los stats base del monstruo elegido (HP, dao base Fis/Wiz/Curse, DefenseBase):
+ `scale = 1 + floor(TotalEnergy / EnergyPerStep) * PercentPerStep`.
+- Los skills de Buff/Regeneration incluyen al summon propio (y los del party) cuando el target es self/party.
+- Cambios de configuracion en caliente; basta con desinvocar y volver a invocar.
+
+Como habilitarlo
+- En el Panel de Administracion: Plugins -> filtrar por "Summon configuration".
+- Vas a ver 7 entradas: "Elf Summon cfg ... (30..36)". Activa las que quieras usar.
+- Edita la "Custom Configuration" de cada una. Campos disponibles:
+ - `MonsterNumber` (int): 0 = usa el mapeo por defecto del servidor; >0 = numero de monstruo a invocar.
+ - `EnergyPerStep` (int): 0 para desactivar; si no, tamao de cada paso de Energia (p.ej. 1000).
+ - `PercentPerStep` (float): incremento por paso (p.ej. 0.05 = +5%).
+
+Notas importantes (si queres usar solo el plugin en otro repo)
+- Ajuste de cache de stats de monstruos (requerido para que el escalado aplique):
+ - En `src/GameLogic/Attributes/MonsterAttributeHolder.cs`, evita cachear por `MonsterDefinition` (igual por Id). Los clones del summon comparten Id; leer por instancia. Incluido en este fork.
+- Evitar dao al propio summon con skills en area (recomendado):
+ - En `src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs` y `src/GameLogic/PlayerActions/Skills/AreaSkillHitAction.cs`, excluir `Monster { SummonedBy == player }` de los targets. Incluido en este fork.
+- Hot-reload: En cada creacion del summon, el plugin lee la CustomConfiguration mas reciente desde la base de datos (sin cache). No hace falta reiniciar; desinvoca y volve a invocar.
+- HUD de "pet": Las invocaciones de elfa no usan el sistema de mascotas por item, por lo que el cliente no muestra esa barra.
+
+Ejemplos de uso
+- +5% por cada 1000 de Energia usando el mob por defecto: `{"MonsterNumber": 0, "EnergyPerStep": 1000, "PercentPerStep": 0.05}`.## Estado actual del proyecto
+
+Este proyecto se encuentra actualmente en desarrollo sin ningún lanzamiento.
+Puedes probar el estado actual utilizando la imagen de docker disponible, mencionada también en la [guía rápida](QuickStart.md).
+
+## Licencia
+
+Este proyecto se publica bajo la licencia MIT (ver archivo LICENSE).
+
+## Tecnologías utilizadas
+
+El proyecto está escrito principalmente en C# y apunta a .NET 9.0.
+
+El panel de administración del servidor se aloja en un servidor web ASP.NET Core embebido (Kestrel) y se implementa como una aplicación Blazor Server.
+
+En este momento la capa de persistencia utiliza [Entity Framework Core](https://github.com/aspnet/EntityFrameworkCore) y [PostgreSQL](https://www.postgresql.org) como base de datos. Además, es posible iniciarlo en un modo no persistente en memoria.
+
+El proyecto soporta alojamiento distribuido basado en Dapr. Alternativamente, también puede alojarse en un solo proceso.
+
+## Despliegue
+
+Proporcionamos imágenes de Docker y archivos docker-compose para un despliegue sencillo.
+Por favor, echa un vistazo a la carpeta deploy de este proyecto.
+
+## Contribuciones
+
+Las contribuciones son bienvenidas si cumplen los siguientes criterios:
+
+* El idioma es inglés.
+* El código debe cumplir con StyleCop; este proyecto usa [StyleCop.Analyzers](https://www.nuget.org/packages/StyleCop.Analyzers/) para VS2022, por lo que deberías ver los problemas directamente como advertencias.
+* El estilo de codificación (nombres, etc.) y la calidad deben ajustarse al estado actual.
+* No debe incluir código copiado/convertido de la conocida fuente descompilada del servidor original.
+
+Si deseas contribuir, crea un nuevo issue para la característica o el error (si el issue aún no existe) para que podamos ver quién está trabajando en algo y discutir posibles soluciones.
+Si es algo pequeño, también puedes enviar un pull request sin añadir un issue.
+
+Además, las contribuciones de personas que no son desarrolladoras también son bienvenidas.
+Puedes probar el servidor, enviar issues o sugerencias, descripciones de paquetes o documentaciones sobre los conceptos y mecánicas del juego.
+Por favor, utiliza archivos/sintaxis markdown para este propósito.
+
+Si tienes preguntas al respecto, no dudes en preguntar en nuestro [canal de Discord](https://discord.gg/2u5Agkd) o creando un issue.
+
+## Cómo contribuir con código
+
+Si deseas contribuir con código, sigue los siguientes pasos:
+
+1. Haz un fork de este proyecto desde el proyecto original MUnique OpenMU.
+2. Crea una rama de características a partir de la rama master.
+3. Haz commit de tus cambios en tu rama.
+4. Envía un pull request a la rama master original.
+5. Relájate, espera la revisión de código y la fusión :)
+
+## Cómo usarlo
+
+Por favor, echa un vistazo a la [guía rápida](QuickStart.md).
+
+## Diferencias de jugabilidad con el servidor original
+
+Este proyecto no tiene como objetivo copiar al 100 % el comportamiento del servidor original de MU Online.
+Esto no es completamente posible, porque el servidor original está escrito en otro lenguaje de programación y tiene una arquitectura completamente diferente.
+En algunos aspectos nos facilitamos la vida en este proyecto y en otros tratamos de mejorar la jugabilidad.
+
+### Cálculos
+
+Los cálculos de los valores de atributos (como decremento del daño del personaje, etc.) se realizan con números de coma flotante de 32 bits y sin redondeo, a diferencia del servidor original en algunos lugares.
+Por ejemplo, los puntos de estadísticas distribuidos siempre tienen efecto, mientras que en el servidor original los efectos pueden redondearse hacia abajo.
+Si 4 puntos de fuerza otorgan 1 de daño base, el servidor original no calcula una fracción de daño para 3 puntos, mientras que OpenMU calcula 0.75 de daño.
+Este daño tiene efecto en cálculos posteriores.
+
+### Cuenta regresiva al cambiar de personaje o sub-servidor
+
+El servidor original utiliza una cuenta regresiva de cinco segundos cuando un jugador quiere cambiar de personaje o de sub-servidor.
+Quizás esto se hizo por razones de rendimiento, ya que el servidor original guardaba los datos del personaje/cuenta.
+Creemos que eso es muy molesto y no vemos un valor real en ello, así que no usamos cuenta regresiva.
+
diff --git a/README.md b/README.md
index fb09623fd..227b9e294 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,15 @@
-# OpenMU Project
+# OpenMU Project / Proyecto OpenMU
[](LICENSE)
-[](https://www.codacy.com/gh/MUnique/OpenMU/dashboard?utm_source=github.com&utm_medium=referral&utm_content=MUnique/OpenMU&utm_campaign=Badge_Grade)
+[](https://www.codacy.com/gh/MUnique/OpenMU/dashboard?utm_source=github.com&utm_medium=referral&utm_content=MUnique/OpenMU&utm_campaign=Badge_Grade)
[](https://gitter.im/OpenMU-Project/Lobby)
[](https://discord.gg/2u5Agkd)
+*Read this README in [English](#english) or [Español](#espanol).*
+
+
+## English
+
| Platform |Build Status |
|----------------|----------------------|
| Windows |  |
@@ -31,6 +36,100 @@ their countless derivates.
There also exists a [blog](https://munique.net) which may contain some valuable
information about this development.
+## Fork changelog
+
+This fork diverges from the original OpenMU project and introduces:
+
+- Bilingual documentation in English and Spanish.
+- LAN presets for Season 6 with deployment overlays for LAN, DNS and npm.
+- proxynet integration replacing nginx and fixing port configuration.
+- Additional Spanish translations and admin panel language fixes.
+- New crafting recipes plus fixes for craftings, skills and grid issues.
+- White Wizard monster + default invasion drop groups.
+- Golden Archer essentials (Rena token, rewards, packed jewels) preconfigured.
+- Rena global drop on Season 1 maps for 0.75 / 0.95d / Season 6.
+
+### Recent updates (2025‑09)
+
+- Docker images hardened and easier to debug:
+ - `src/Startup/Dockerfile` now accepts `ASPNET_IMAGE`/`SDK_IMAGE` build args, installs ICU (`icu-libs`, `icu-data-full`) and sets `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false` to enable proper cultures on Alpine.
+ - Uses `ARG APP_UID` (default `1000`) to avoid failures when no user id is provided.
+ - Builds with `-v m` for detailed logs and copies `strings.*.json` to `/app/Localization` during publish.
+- Localization refactor:
+ - New robust `LocalizationService` (core) with safe culture fallback to Invariant when ICU is unavailable.
+ - Single DI registration (singleton) shared by server and Admin Panel.
+ - Admin components explicitly inject the core service to avoid type ambiguity.
+ - Language selector changes the server language at runtime (affects in‑game messages which use localization APIs).
+- Admin Panel reliability:
+ - Fixed blank page caused by ambiguous DI type for `LocalizationService`.
+ - Added simple log tail endpoint: `GET /api/logs/tail?take=200`.
+- Build fixes:
+ - Removed duplicate assembly attributes in `src/Localization` by setting `false`.
+ - Fixed Linux path casing and project reference for Admin Panel localization resources.
+ - Minor C# fix in `LetterSendAction` to avoid variable shadowing under `-p:ci=true`.
+
+### Elf Summon Plug-in
+
+This fork includes a configurable plug-in to change Elf summons (skills 30..36) and scale their stats by Energy without restarting the server.
+
+Code location: src/GameLogic/PlugIns/ElfSummonsAll.cs.
+
+What it provides
+- Replace the summoned monster per skill (30..36) or keep the default mapping.
+- Dynamic scaling by Energy applied to the base stats of the chosen monster (HP, base damage Phys/Wiz/Curse, DefenseBase):
+ scale = 1 + floor(TotalEnergy / EnergyPerStep) * PercentPerStep.
+- Buff/regeneration skills also include your own summon (and party members' summons) when the target mode is self/party.
+- Apply configuration changes at runtime; just unsummon and summon again.
+
+How to enable
+- In the Admin Panel: Plugins → filter by "Summon configuration".
+- You will see 7 entries: "Elf Summon cfg ... (30..36)". Activate the ones you need.
+- Edit the "Custom Configuration" of each. Available fields:
+ - MonsterNumber (int): 0 = use the server default mapping; >0 = monster number to summon.
+ - EnergyPerStep (int): 0 to disable; otherwise size of each Energy step (e.g. 1000).
+ - PercentPerStep (float): added per step (e.g. 0.05 = +5%).
+
+Important notes (for using this plug-in in another repo)
+- Monster stat cache adjustment (required so scaling applies to summons):
+ - In src/GameLogic/Attributes/MonsterAttributeHolder.cs, don't cache by MonsterDefinition (equals by Id). Summoned clones share Id; read attributes per-instance instead. Included in this fork.
+- Prevent damage to your own summon with area skills (recommended):
+ - In src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs and src/GameLogic/PlayerActions/Skills/AreaSkillHitAction.cs, exclude Monster { SummonedBy == player } from targets. Included in this fork.
+- Configuration hot-reload: On each summon creation, the plug-in fetches the latest CustomConfiguration from the database (no cache). No restart required; just re-summon.
+- Pet HUD (Fenrir/Raven bar): Elf summons don't use the item-pet system, so the stock client doesn't show that bar. Name/owner display is supported. Pet HUD would require client changes.
+
+Examples
+- +5% per 1000 Energy using default monster: {"MonsterNumber": 0, "EnergyPerStep": 1000, "PercentPerStep": 0.05}.
+
+### Rena & Golden Archer
+
+What’s included
+- Rena token (group 14, number 21) added if missing.
+- Drop groups: "Golden Archer Rewards" and "Golden Archer Packed Jewels" with common jewels.
+- Golden Archer NPC plug-in preconfigured and enabled (uses Rena as token; Box of Luck/Heaven and reward groups).
+
+Global Rena Drops on S1 maps
+- Optional updates per version: "Add Rena Global Drop (0.75)", "(0.95d)", "(Season 6)".
+- Adds drop group "Rena Global Drop (S1 maps)" to Lorencia, Noria, Devias, Dungeon, Lost Tower, Atlans, Arena, Exile.
+- Default chance: 0.2% per kill (editable in Admin Panel by editing the drop group Chance).
+
+How to apply/update
+- Admin Panel → Updates: select
+ - "Golden Archer: Rena + Reward Group"
+ - "Add Rena Global Drop (…version…)"
+ and click Apply. They are non-mandatory and safe to re-run.
+
+Notes
+- To use the Golden Archer, ensure the NPC exists in your game setup; the plug-in is already enabled and uses the configured drop groups.
+
+### White Wizard & Invasions
+
+What’s included
+- Adds White Wizard monster (id 135) if missing, and default drop groups for support mobs and boss.
+- Designed for Season 6 data; safe to apply over existing configs.
+
+How to apply/update
+- Admin Panel → Updates: select "White Wizard Monster and Invasion Drops" and Apply.
+
## Current project state
This project is currently under development without any release.
@@ -60,17 +159,64 @@ hosted in one process as well.
We provide Docker images and docker-compose files for easy deployment.
Please take a look at the deploy-folder of this project.
+### Deploy from a remote fork (compose overlay)
+
+- Overlays in `deploy/all-in-one` allow building the image directly from your fork using a remote git context.
+- Required env var: `OPENMU_FORK_CONTEXT` in the form `https://github.com//OpenMU-.git#:src`.
+- Example commands (server):
+ - `export OPENMU_FORK_CONTEXT="https://github.com/EmanuelCatania/OpenMU-S2.git#master:src"`
+ - `docker compose -f docker-compose.no-nginx.yml -f docker-compose.override.yml -f docker-compose.public-dns.yml -f docker-compose.npm-net.yml -f docker-compose.from-fork.yml build --no-cache --progress=plain --build-arg APP_UID=1000 openmu-startup`
+ - `docker compose -f docker-compose.no-nginx.yml -f docker-compose.public-dns.yml -f docker-compose.npm-net.yml -f docker-compose.from-fork.yml up -d --no-deps --force-recreate openmu-startup`
+
+Notes
+- `src/Startup/Dockerfile` installs ICU (`icu-libs`, `icu-data-full`) and sets `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false` to enable real cultures on Alpine.
+- If you are behind a proxy (NPM/Cloudflare), enable WebSockets for `/_blazor` and avoid orange cloud (DNS only) for the host.
+
+## Localization
+
+- Core service: `src/Localization/LocalizationService.cs` reads `strings.*.json` from a configurable `ResourceDirectory` (defaults to `/app/Localization` in Docker).
+- Admin Panel components inject the core service and react to language changes (`LanguageChanged` event).
+- Changing the language from the UI updates the singleton service used by the server as well; server messages which call `GetLocalizedMessage(...)` reflect the new language immediately.
+- On restart the language falls back to the configuration in `src/Startup/appsettings.json` (`Localization:DefaultLanguage`, `Localization:CurrentLanguage`).
+- When ICU is not available, cultures gracefully fall back to `InvariantCulture` so the server keeps running.
+
+## Troubleshooting
+
+- Build: `base name (${SDK_IMAGE}) should not be blank`
+ - Declare `ARG SDK_IMAGE=...` before the first `FROM`. Already fixed in `src/Startup/Dockerfile`.
+- Build: duplicate assembly attributes (CS0579) in Localization
+ - Set `false` in `src/Localization/MUnique.OpenMU.Localization.csproj`. Already applied.
+- Runtime: crash with `CultureNotFoundException` in globalization‑invariant mode
+ - The Dockerfile installs ICU and sets `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false`. Alternatively, the service now falls back to `InvariantCulture`.
+- Admin Panel: blank page and console error `Cannot provide a value for property 'Localization' ...`
+ - Caused by ambiguous DI type; components explicitly inject the core service (`MUnique.OpenMU.Localization.LocalizationService`). Fixed in `src/Web/AdminPanel/Localization/LocalizedComponentBase.cs` and `src/Web/AdminPanel/Localization/LocalizedLayoutComponentBase.cs`.
+- Admin Panel: 404 for `/_content/...` or SignalR disconnects
+ - Ensure your proxy forwards static assets and enables WebSockets for `/_blazor`. Try direct access to the host/port to isolate proxy issues.
+- Logs
+ - Tail recent logs over HTTP: `GET /api/logs/tail?take=200`.
+ - Or use `docker logs -n 200 openmu-startup`.
+
+### Deploy from a remote fork (compose overlay)
+
+- Overlays in `deploy/all-in-one` allow building the image directly from your fork using a remote git context.
+- Required env var: `OPENMU_FORK_CONTEXT` in the form `https://github.com//OpenMU-.git#:src`.
+- Example commands (server):
+ - `export OPENMU_FORK_CONTEXT="https://github.com/EmanuelCatania/OpenMU-S2.git#master:src"`
+ - `docker compose -f docker-compose.no-nginx.yml -f docker-compose.override.yml -f docker-compose.public-dns.yml -f docker-compose.npm-net.yml -f docker-compose.from-fork.yml build --no-cache --progress=plain --build-arg APP_UID=1000 openmu-startup`
+ - `docker compose -f docker-compose.no-nginx.yml -f docker-compose.public-dns.yml -f docker-compose.npm-net.yml -f docker-compose.from-fork.yml up -d --no-deps --force-recreate openmu-startup`
+
+Notes
+- `src/Startup/Dockerfile` installs ICU (`icu-libs`, `icu-data-full`) and sets `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false` to enable real cultures on Alpine.
+- If you are behind a proxy (NPM/Cloudflare), enable WebSockets for `/_blazor` and avoid orange cloud (DNS only) for the host.
+
## Contributions
Contributions are welcome if they meet the following criteria:
* Language is english.
-
* Code should be StyleCop compliant - this project uses the [StyleCop.Analyzers](https://www.nuget.org/packages/StyleCop.Analyzers/)
for VS2022 so you should see issues directly as warnings.
-
* Coding style (naming, etc.) and quality should fit to the current state.
-
* No code copied/converted from the well-known decompiled source of the
original server.
@@ -128,3 +274,174 @@ his character or the sub-server. Maybe this was done for some performance
reasons, as the original server would then save the character/account data.
We think that's really annoying and see no real value in that, so we don't use
a countdown.
+
+
+## Español
+
+| Plataforma | Estado de compilación |
+|-----------------|-------------------------------|
+| Windows |  |
+| Linux (Docker) | [](https://hub.docker.com/r/munique/openmu) |
+
+| Paquetes NuGet | |
+|----------------|---|
+| MUnique.OpenMU.Network | [](https://www.nuget.org/packages/MUnique.OpenMU.Network/) |
+| MUnique.OpenMU.Network.Packets | [](https://www.nuget.org/packages/MUnique.OpenMU.Network.Packets/) |
+
+Este proyecto tiene como objetivo crear un servidor fácil de usar, ampliable y personalizable para un MMORPG llamado "MU Online".
+El servidor admite múltiples versiones del juego, pero el enfoque principal es la versión de la Season 6 Episode 3 utilizando el protocolo ENG (inglés). Además, el enfoque a largo plazo está en el [cliente de código abierto](https://github.com/sven-n/MuMain), que soporta un protocolo de red ligeramente ampliado.
+Sin embargo, partes del software también pueden ser adecuadas para el desarrollo de otros juegos, incluso de otro tipo.
+
+El código es una reescritura completa desde cero; no se basa en proyectos preexistentes ni en fuentes de servidor descompiladas ni en sus innumerables derivados.
+
+También existe un [blog](https://munique.net) que puede contener información valiosa sobre este desarrollo.
+
+## Cambios del fork
+
+Este fork se desvía del proyecto original OpenMU e introduce:
+
+- Documentación bilingüe en inglés y español.
+- Presets LAN para Season 6 con overlays de despliegue para LAN, DNS y npm.
+- Integración de proxynet reemplazando nginx y corrigiendo la configuración de puertos.
+- Traducciones adicionales al español y correcciones de idioma del panel de administración.
+- Nuevas recetas de crafteo y correcciones para crafteo, habilidades y problemas de cuadrícula.
+- Mago Blanco (White Wizard) + grupos de drop por defecto para la invasión.
+- Golden Archer listo (token Rena, recompensas y joyas empaquetadas) preconfigurado.
+- Drop global de Rena en mapas de Season 1 para 0.75 / 0.95d / Season 6.
+
+### Plugin de invocaciones de Elfa
+
+Este fork incluye un plugin configurable para cambiar las invocaciones de la Elfa (skills 30..36) y escalar sus stats en base a la Energía, sin reiniciar el servidor.
+
+Ubicación del código: src/GameLogic/PlugIns/ElfSummonsAll.cs.
+
+Qué permite
+- Reemplazar el monstruo invocado por cada skill (30..36) o mantener el mapeo por defecto.
+- Escalado por Energía aplicado a los stats base del monstruo elegido (HP, daño base Fis/Wiz/Curse, DefenseBase):
+ scale = 1 + floor(TotalEnergy / EnergyPerStep) * PercentPerStep.
+- Los skills de Buff/Regeneración incluyen al summon propio (y los del party) cuando el target es self/party.
+- Cambios de configuración en caliente; basta con desinvocar y volver a invocar.
+
+Cómo habilitarlo
+- En el Panel de Administración: Plugins → filtrar por "Summon configuration".
+- Verás 7 entradas: "Elf Summon cfg ... (30..36)". Activa las que quieras usar.
+- Edita la "Custom Configuration" de cada una. Campos disponibles:
+ - MonsterNumber (int): 0 = usa el mapeo por defecto del servidor; >0 = número de monstruo a invocar.
+ - EnergyPerStep (int): 0 para desactivar; si no, tamaño de cada paso de Energía (p.ej. 1000).
+ - PercentPerStep (float): incremento por paso (p.ej. 0.05 = +5%).
+
+Notas importantes (si quieres usar solo el plugin en otro repo)
+- Ajuste de caché de stats de monstruos (requerido para que el escalado aplique):
+ - En src/GameLogic/Attributes/MonsterAttributeHolder.cs, evita cachear por MonsterDefinition (igual por Id). Los clones del summon comparten Id; lee por instancia. Incluido en este fork.
+- Evitar daño al propio summon con skills en área (recomendado):
+ - En src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs y src/GameLogic/PlayerActions/Skills/AreaSkillHitAction.cs, excluir Monster { SummonedBy == player } de los targets. Incluido en este fork.
+- Hot-reload: En cada creación del summon, el plugin lee la CustomConfiguration más reciente desde la base de datos (sin caché). No hace falta reiniciar; desinvoca y vuelve a invocar.
+- HUD de "pet": Las invocaciones de elfa no usan el sistema de mascotas por ítem, por lo que el cliente no muestra esa barra.
+
+Ejemplos de uso
+- +5% por cada 1000 de Energía usando el mob por defecto: {"MonsterNumber": 0, "EnergyPerStep": 1000, "PercentPerStep": 0.05}.
+
+### Rena y Golden Archer
+
+Qué incluye
+- Token Rena (grupo 14, número 21) si falta.
+- Grupos de drop: "Golden Archer Rewards" y "Golden Archer Packed Jewels" con joyas comunes.
+- Plug-in del NPC Golden Archer preconfigurado y habilitado (usa Rena como ficha; Box of Luck/Heaven y grupos de recompensa).
+
+Drop global de Rena en mapas S1
+- Updates opcionales por versión: "Add Rena Global Drop (0.75)", "(0.95d)", "(Season 6)".
+- Agrega el grupo "Rena Global Drop (S1 maps)" a Lorencia, Noria, Devias, Dungeon, Lost Tower, Atlans, Arena, Exile.
+- Chance por defecto: 0.2% por mob (editable en el Admin Panel editando el Chance del grupo).
+
+Cómo aplicarlo/actualizar
+- Admin Panel → Updates: seleccionar
+ - "Golden Archer: Rena + Reward Group"
+ - "Add Rena Global Drop (…versión…)"
+ y aplicar. No son obligatorios y son seguros de re-ejecutar.
+
+Notas
+- Para usar el Golden Archer, asegurate de tener el NPC en el mapa/escenario; el plug-in ya está habilitado y usa los grupos configurados.
+
+### White Wizard e Invasiones
+
+Qué incluye
+- Agrega el monstruo White Wizard (id 135) si falta y grupos de drops por defecto para mobs de soporte y el boss.
+- Diseñado para datos de Season 6; es seguro aplicarlo sobre configuraciones existentes.
+
+Cómo aplicarlo/actualizar
+- Admin Panel → Updates: seleccionar "White Wizard Monster and Invasion Drops" y aplicar.
+
+## Estado actual del proyecto
+
+Este proyecto se encuentra actualmente en desarrollo sin ningún lanzamiento.
+Puedes probar el estado actual utilizando la imagen de docker disponible, mencionada también en la [guía rápida](QuickStart.md).
+
+## Licencia
+
+Este proyecto se publica bajo la licencia MIT (ver archivo LICENSE).
+
+## Tecnologías utilizadas
+
+El proyecto está escrito principalmente en C# y apunta a .NET 9.0.
+
+El panel de administración del servidor se aloja en un servidor web ASP.NET Core embebido (Kestrel) y se implementa como una aplicación Blazor Server.
+
+En este momento la capa de persistencia utiliza [Entity Framework Core](https://github.com/aspnet/EntityFrameworkCore) y [PostgreSQL](https://www.postgresql.org) como base de datos. Además, es posible iniciarlo en un modo no persistente en memoria.
+
+El proyecto soporta alojamiento distribuido basado en Dapr. Alternativamente, también puede alojarse en un solo proceso.
+
+## Despliegue
+
+Proporcionamos imágenes de Docker y archivos docker-compose para un despliegue sencillo.
+Por favor, echa un vistazo a la carpeta deploy de este proyecto.
+
+## Contribuciones
+
+Las contribuciones son bienvenidas si cumplen los siguientes criterios:
+
+* El idioma es inglés.
+* El código debe cumplir con StyleCop; este proyecto usa [StyleCop.Analyzers](https://www.nuget.org/packages/StyleCop.Analyzers/) para VS2022, por lo que deberías ver los problemas directamente como advertencias.
+* El estilo de codificación (nombres, etc.) y la calidad deben ajustarse al estado actual.
+* No debe incluir código copiado/convertido de la conocida fuente descompilada del servidor original.
+
+Si deseas contribuir, crea un nuevo issue para la característica o el error (si el issue aún no existe) para que podamos ver quién está trabajando en algo y discutir posibles soluciones.
+Si es algo pequeño, también puedes enviar un pull request sin añadir un issue.
+
+Además, las contribuciones de personas que no son desarrolladoras también son bienvenidas.
+Puedes probar el servidor, enviar issues o sugerencias, descripciones de paquetes o documentaciones sobre los conceptos y mecánicas del juego.
+Por favor, utiliza archivos/sintaxis markdown para este propósito.
+
+Si tienes preguntas al respecto, no dudes en preguntar en nuestro [canal de Discord](https://discord.gg/2u5Agkd) o creando un issue.
+
+## Cómo contribuir con código
+
+Si deseas contribuir con código, sigue los siguientes pasos:
+
+1. Haz un fork de este proyecto desde el proyecto original MUnique OpenMU.
+2. Crea una rama de características a partir de la rama master.
+3. Haz commit de tus cambios en tu rama.
+4. Envía un pull request a la rama master original.
+5. Relájate, espera la revisión de código y la fusión :)
+
+## Cómo usarlo
+
+Por favor, echa un vistazo a la [guía rápida](QuickStart.md).
+
+## Diferencias de jugabilidad con el servidor original
+
+Este proyecto no tiene como objetivo copiar al 100 % el comportamiento del servidor original de MU Online.
+Esto no es completamente posible, porque el servidor original está escrito en otro lenguaje de programación y tiene una arquitectura completamente diferente.
+En algunos aspectos nos facilitamos la vida en este proyecto y en otros tratamos de mejorar la jugabilidad.
+
+### Cálculos
+
+Los cálculos de los valores de atributos (como decremento del daño del personaje, etc.) se realizan con números de coma flotante de 32 bits y sin redondeo, a diferencia del servidor original en algunos lugares.
+Por ejemplo, los puntos de estadísticas distribuidos siempre tienen efecto, mientras que en el servidor original los efectos pueden redondearse hacia abajo.
+Si 4 puntos de fuerza otorgan 1 de daño base, el servidor original no calcula una fracción de daño para 3 puntos, mientras que OpenMU calcula 0.75 de daño.
+Este daño tiene efecto en cálculos posteriores.
+
+### Cuenta regresiva al cambiar de personaje o sub-servidor
+
+El servidor original utiliza una cuenta regresiva de cinco segundos cuando un jugador quiere cambiar de personaje o de sub-servidor.
+Quizás esto se hizo por razones de rendimiento, ya que el servidor original guardaba los datos del personaje/cuenta.
+Creemos que eso es muy molesto y no vemos un valor real en ello, así que no usamos cuenta regresiva.
diff --git a/deploy/all-in-one/README.md b/deploy/all-in-one/README.md
index 034b64dda..a9659668c 100644
--- a/deploy/all-in-one/README.md
+++ b/deploy/all-in-one/README.md
@@ -87,3 +87,80 @@ should be created.
If you click on 'Install', wait a bit until the database is set up and filled with the
data and voila, OpenMU is ready to use.
+
+## Presets for LAN vs Public
+
+To simplify IP resolution for clients, additional compose overlays are provided:
+
+- LAN testing (announce local IP):
+
+ `docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.lan.yml up -d --build`
+
+- Public internet (announce public IP):
+
+ `docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.public.yml up -d`
+
+You can switch between them by recreating the `openmu-startup` service with the respective overlays.
+
+### When port 80 is in use (existing reverse proxy)
+
+If your host already uses port 80/443 (e.g., Nginx Proxy Manager), you have two options:
+
+1) Disable the bundled nginx and proxy via your reverse proxy:
+
+ - Start without our nginx service: `docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.lan.yml up -d --build --scale nginx-80=0`
+ - In your reverse proxy, forward to `openmu-startup:8080` (container name and port) with WebSockets enabled.
+
+2) Map the admin panel to an alternate host port (e.g., 8082):
+
+ - `docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.lan.yml -f docker-compose.admin-port.yml up -d --build --scale nginx-80=0`
+ - Access the admin panel at `http://:8082/`.
+
+### Run without bundled nginx (use your existing reverse proxy)
+
+If you prefer to use your own nginx / reverse proxy, use the `docker-compose.no-nginx.yml` file which excludes the bundled nginx service and exposes the admin panel on host port 8082.
+
+- LAN:
+
+ `docker compose -f docker-compose.no-nginx.yml -f docker-compose.override.yml -f docker-compose.lan.yml up -d --build`
+
+- Public:
+
+ `docker compose -f docker-compose.no-nginx.yml -f docker-compose.public.yml up -d`
+
+Then, configure your reverse proxy to forward to `http://:8082/` with WebSockets enabled.
+
+### Attach to your existing Nginx Proxy (Docker network)
+
+If your Nginx Proxy Manager (or nginx) runs on an external Docker network (e.g. `proxy_net`), attach `openmu-startup` to that network so the proxy can reach it by container name:
+
+1) Ensure the network exists:
+
+ `docker network ls | grep proxy_net || docker network create proxy_net`
+
+2) Start OpenMU attached to `proxy_net`:
+
+ `docker compose -f docker-compose.no-nginx.yml -f docker-compose.override.yml -f docker-compose.lan.yml -f docker-compose.npm-net.yml up -d --build`
+
+3) In your proxy (NPM):
+
+ - Forward Hostname/IP: `openmu-startup`
+ - Forward Port: `8080`
+ - Scheme: `http`
+ - WebSockets: enabled
+
+### Use a fixed public IP without committing it
+
+If you prefer a fixed public IP instead of DNS but don't want to commit it to the repo:
+
+1) Copy the example overlay:
+
+ `cp docker-compose.public-ip.example.yml docker-compose.public-ip.yml`
+
+2) Edit `docker-compose.public-ip.yml` and set your public IP in `RESOLVE_IP`.
+
+3) Start with:
+
+ `docker compose -f docker-compose.no-nginx.yml -f docker-compose.public-ip.yml -f docker-compose.npm-net.yml up -d --build --no-deps openmu-startup`
+
+Note: `docker-compose.public-ip.yml` is git-ignored by default.
diff --git a/deploy/all-in-one/docker-compose.admin-port.yml b/deploy/all-in-one/docker-compose.admin-port.yml
new file mode 100644
index 000000000..f276e1b80
--- /dev/null
+++ b/deploy/all-in-one/docker-compose.admin-port.yml
@@ -0,0 +1,16 @@
+services:
+ openmu-startup:
+ ports:
+ # Fija el puerto del panel a 8082 en el host.
+ - "8082:8080"
+ # Reexpone puertos del juego y chat como en el base file.
+ - "55901:55901"
+ - "55902:55902"
+ - "55903:55903"
+ - "55904:55904"
+ - "55905:55905"
+ - "55906:55906"
+ - "44405:44405"
+ - "44406:44406"
+ - "55980:55980"
+
diff --git a/deploy/all-in-one/docker-compose.from-fork.yml b/deploy/all-in-one/docker-compose.from-fork.yml
new file mode 100644
index 000000000..2d62f7752
--- /dev/null
+++ b/deploy/all-in-one/docker-compose.from-fork.yml
@@ -0,0 +1,15 @@
+services:
+
+ openmu-startup:
+ # Always build the image from your fork (remote git context)
+ # You can override the context with an env var OPENMU_FORK_CONTEXT
+ # Example value: https://github.com/EmanuelCatania/OpenMU-S2.git#master:src
+ image: openmu-fork:dev
+ build:
+ context: ${OPENMU_FORK_CONTEXT:-https://github.com/EmanuelCatania/OpenMU-S2.git#master:src}
+ dockerfile: Startup/Dockerfile
+ pull_policy: never
+ restart: "no"
+ environment:
+ # Enable detailed summon diagnostics in server logs
+ SUMMON_DIAG: "true"
diff --git a/deploy/all-in-one/docker-compose.lan-ip.yml b/deploy/all-in-one/docker-compose.lan-ip.yml
new file mode 100644
index 000000000..267987d3b
--- /dev/null
+++ b/deploy/all-in-one/docker-compose.lan-ip.yml
@@ -0,0 +1,7 @@
+services:
+ openmu-startup:
+ environment:
+ # Anuncia explícitamente la IP LAN del host al cliente.
+ # Ajusta este valor a la IP correcta del servidor en tu red.
+ RESOLVE_IP: "192.168.100.200"
+
diff --git a/deploy/all-in-one/docker-compose.lan.yml b/deploy/all-in-one/docker-compose.lan.yml
new file mode 100644
index 000000000..eed023f67
--- /dev/null
+++ b/deploy/all-in-one/docker-compose.lan.yml
@@ -0,0 +1,6 @@
+services:
+ openmu-startup:
+ environment:
+ # Anuncia la IP local a los clientes (uso en red local).
+ RESOLVE_IP: local
+
diff --git a/deploy/all-in-one/docker-compose.no-nginx.yml b/deploy/all-in-one/docker-compose.no-nginx.yml
new file mode 100644
index 000000000..a8fcebee5
--- /dev/null
+++ b/deploy/all-in-one/docker-compose.no-nginx.yml
@@ -0,0 +1,39 @@
+services:
+ openmu-startup:
+ image: munique/openmu
+ container_name: openmu-startup
+ ports:
+ # Admin Panel expuesto en 8082 del host (para que NPM/tu nginx lo consuma)
+ - "8082:8080"
+ # Puertos de juego y chat
+ - "55901:55901"
+ - "55902:55902"
+ - "55903:55903"
+ - "55904:55904"
+ - "55905:55905"
+ - "55906:55906"
+ - "44405:44405"
+ - "44406:44406"
+ - "55980:55980"
+ environment:
+ DB_HOST: database
+ ASPNETCORE_URLS: http://+:8080
+ working_dir: /app/
+ depends_on:
+ - database
+
+ database:
+ image: postgres
+ container_name: database
+ environment:
+ POSTGRES_PASSWORD: admin
+ POSTGRES_DB: openmu
+ POSTGRES_USER: postgres
+ ports:
+ - "5432"
+ volumes:
+ - dbdata:/var/lib/postgresql/data
+
+volumes:
+ dbdata:
+
diff --git a/deploy/all-in-one/docker-compose.npm-net.yml b/deploy/all-in-one/docker-compose.npm-net.yml
new file mode 100644
index 000000000..fddcae81d
--- /dev/null
+++ b/deploy/all-in-one/docker-compose.npm-net.yml
@@ -0,0 +1,10 @@
+networks:
+ proxy_net:
+ external: true
+
+services:
+ openmu-startup:
+ networks:
+ - default
+ - proxy_net
+
diff --git a/deploy/all-in-one/docker-compose.override.yml b/deploy/all-in-one/docker-compose.override.yml
index 1c7a21a7b..4b6b8f879 100644
--- a/deploy/all-in-one/docker-compose.override.yml
+++ b/deploy/all-in-one/docker-compose.override.yml
@@ -1,12 +1,26 @@
services:
openmu-startup:
+ image: openmu-local:dev
build:
context: ../../src
dockerfile: Startup/Dockerfile
+ pull_policy: never
restart: "no"
- ports:
- - "8081:8080"
+ environment:
+ # Para pruebas en red local, anunciar IP local a los clientes.
+ # Cambiar a "public" si van a conectarse desde Internet.
+ RESOLVE_IP: local
+ Logging__LogLevel__Default: Debug
+ Logging__LogLevel__MUnique: Debug
+ Logging__LogLevel__MUnique.OpenMU.ConnectServer: Debug
+ Logging__LogLevel__MUnique.OpenMU.GameServer: Debug
+ Logging__LogLevel__MUnique.OpenMU.Network: Debug
+ Serilog__MinimumLevel__Default: Debug
+ Serilog__MinimumLevel__Override__MUnique: Debug
+ Serilog__MinimumLevel__Override__MUnique.OpenMU.ConnectServer: Debug
+ Serilog__MinimumLevel__Override__MUnique.OpenMU.GameServer: Debug
+ Serilog__MinimumLevel__Override__MUnique.OpenMU.Network: Debug
database:
ports:
diff --git a/deploy/all-in-one/docker-compose.public-dns.yml b/deploy/all-in-one/docker-compose.public-dns.yml
new file mode 100644
index 000000000..8f2a06f87
--- /dev/null
+++ b/deploy/all-in-one/docker-compose.public-dns.yml
@@ -0,0 +1,7 @@
+services:
+ openmu-startup:
+ environment:
+ # Anuncia el nombre DNS (resuelto por el cliente) en lugar de una IP fija.
+ # Asegúrate de que el registro A de este host esté en Cloudflare como DNS only (nube gris).
+ RESOLVE_IP: "200.123.115.104"
+
diff --git a/deploy/all-in-one/docker-compose.public-ip.example.yml b/deploy/all-in-one/docker-compose.public-ip.example.yml
new file mode 100644
index 000000000..f190f10c2
--- /dev/null
+++ b/deploy/all-in-one/docker-compose.public-ip.example.yml
@@ -0,0 +1,7 @@
+services:
+ openmu-startup:
+ environment:
+ # EJEMPLO: Ajusta con tu IP pública real y guarda como 'docker-compose.public-ip.yml'.
+ # Este archivo de ejemplo SÍ se versiona; el real 'docker-compose.public-ip.yml' está ignorado por .gitignore.
+ RESOLVE_IP: "1.2.3.4"
+
diff --git a/deploy/all-in-one/docker-compose.public.yml b/deploy/all-in-one/docker-compose.public.yml
new file mode 100644
index 000000000..0296f3e08
--- /dev/null
+++ b/deploy/all-in-one/docker-compose.public.yml
@@ -0,0 +1,6 @@
+services:
+ openmu-startup:
+ environment:
+ # Anuncia la IP pública a los clientes (uso por Internet).
+ RESOLVE_IP: public
+
diff --git a/deploy/all-in-one/docker-compose.yml b/deploy/all-in-one/docker-compose.yml
index 725620b1a..2cce3cbae 100644
--- a/deploy/all-in-one/docker-compose.yml
+++ b/deploy/all-in-one/docker-compose.yml
@@ -46,4 +46,4 @@ services:
- dbdata:/var/lib/postgresql #store data on volume
volumes:
- dbdata:
\ No newline at end of file
+ dbdata:
diff --git a/docs/CHANGES_SUMMON_AND_LOGS.md b/docs/CHANGES_SUMMON_AND_LOGS.md
new file mode 100644
index 000000000..1aadbf83e
--- /dev/null
+++ b/docs/CHANGES_SUMMON_AND_LOGS.md
@@ -0,0 +1,22 @@
+# Recent changes (Summon and Admin Panel)
+
+## Summon (Elf) fixes and scaling
+
+- Summon base stats are now loaded and cloned correctly (EF types) so values are no longer zero.
+- Energy-based scaling can be configured from the Admin Panel (plug-in "Elf Summon cfg — 30..36").
+ - Formula: `scale = 1 + floor(Energy / EnergyPerStep) * PercentPerStep`.
+ - If the plug-in is not active, stored configuration values are still respected.
+- Classic behavior restored:
+ - Summon follows the owner inside safezone.
+ - Summon never aggroes the owner even if the owner hits it.
+ - Direct and area skills do not hit own summon (basic attack with CTRL is handled by the client).
+- Optional diagnostics: set environment variable `SUMMON_DIAG=1` to enable detailed server logs.
+
+## Live Logs in Admin Panel
+
+- New page: "Logs en Vivo" in the left menu (below "Archivos de Log").
+- Endpoint configurable via env var `LOG_TAIL_URL`.
+ - Default: `/api/logs/tail?take=200`
+ - Example: `https://mu.server-pups.space/api/logs/tail?take=200`
+- Includes line count, text filter and auto-refresh (3s).
+
diff --git a/docs/GameMap.md b/docs/GameMap.md
index 019597580..ca8de9e04 100644
--- a/docs/GameMap.md
+++ b/docs/GameMap.md
@@ -1,4 +1,9 @@
-# GameMap
+# GameMap / Mapa del Juego
+
+*Read this document in [English](#english) or [Español](#espanol).*
+
+
+## English
I want to give a quick description about how the [game map](../src/GameLogic/GameMap.cs),
especially observing between players/npc works.
@@ -51,3 +56,55 @@ Instead of partitioning a map into buckets (where a lot of them are empty), we
could try to use some other 2d index structures, like
[R-trees](https://en.wikipedia.org/wiki/R-tree) or [B-Trees](https://en.wikipedia.org/wiki/B-tree)
with [Z-Ordering](https://en.wikipedia.org/wiki/Z-order_curve).
+
+
+## Español
+
+Quiero dar una rápida descripción de cómo funciona el [game map](../src/GameLogic/GameMap.cs),
+especialmente la observación entre players y npc.
+
+## AreaOfInterestManager
+
+Cada game map tiene un area of interest manager. Se encarga de las
+suscripciones a eventos entre objetos, en caso de que se agreguen, muevan o
+eliminen en el map.
+
+La implementación por defecto ([BucketAreaOfInterestManager](../src/GameLogic/BucketAreaOfInterestManager.cs))
+crea un BucketMap, que se describe abajo. Otras implementaciones más simples
+podrían ser posibles en el futuro para maps que probablemente estén vacíos o
+requieran que todos los objetos de un map conozcan a todos los demás objetos
+(por ejemplo, el Duel map).
+
+## BucketMap
+
+El [bucket map](../src/GameLogic/BucketMap{T}.cs) es básicamente una estructura
+de datos bidimensional. Dividimos el map de 256 x 256 coordenadas posibles en
+"[buckets](../src/GameLogic/Bucket{T}.cs)". Por ejemplo, cada bucket cubre
+8 x 8 coordenadas.
+
+Un player (u otros "observers") puede observar buckets para ser informado
+cuando objetos (otros players, npc, items dropped) entren o salgan de ese
+bucket. Cuando un player se mueve y entra en el rango de un bucket (definido
+por un "info range"), se suscribe a estos eventos de entrada/salida y agrega
+todos los objetos existentes de ese bucket a su view.
+
+Podrías preguntarte por qué introduje esta estructura y no simplemente usar una
+lista de objetos y comparar la distancia al moverse para decidir si un objeto
+debería enviarse (add/remove) al game client. Según sé, el server original
+funciona así y tiene mucho lag cuando hay muchos players (más de mil en castle
+siege) en el mismo map, todos moviéndose. Es un problema O(n²) donde n es el
+número de objetos en el mismo map, lo cual se vuelve complejo rápidamente.
+
+Con este concepto, la búsqueda solo ocurre cuando un player entra en un bucket
+nuevo, y entonces busca solo nuevos buckets en rango, no otros objetos
+individuales. Así no es tan complejo cuando tienes muchos objetos en tu map. Por
+supuesto, esto requiere un poco más de memoria y es más lento en maps vacíos,
+pero en mi opinión vale la pena; usualmente los game maps están llenos de
+monsters, players y muchos items dropped.
+
+## Otras ideas
+
+En lugar de dividir un map en buckets (donde muchos están vacíos), podríamos
+usar otras estructuras de índice 2D, como
+[R-trees](https://en.wikipedia.org/wiki/R-tree) o [B-Trees](https://en.wikipedia.org/wiki/B-tree)
+con [Z-Ordering](https://en.wikipedia.org/wiki/Z-order_curve).
diff --git a/docs/MasterSystem.md b/docs/MasterSystem.md
index 5f53ed1cd..27a31231c 100644
--- a/docs/MasterSystem.md
+++ b/docs/MasterSystem.md
@@ -1,4 +1,9 @@
-# Master System
+# Master System / Sistema Maestro
+
+*Read this document in [English](#english) or [Español](#espanol).*
+
+
+## English
## What is it?
@@ -114,3 +119,106 @@ Some short notes about the client:
* For code, see [UpdateMasterSkillsPlugIn](https://github.com/MUnique/OpenMU/tree/master/src/GameServer/RemoteView/Character/UpdateMasterSkillsPlugIn.cs).
* Packet: [C2F353 - Master Skill List](Packets/C2-F3-53-MasterSkillList_by-server.md)
+
+
+## Español
+
+## ¿Qué es?
+
+Cuando un personaje alcanza cierto level (usualmente level 400) y completa una
+quest, su character class cambia a una *master character class*. Esto desbloquea
+el *Master Skill Tree*, que permite distribuir points a master skills. Los points
+se otorgan por cada master level que el player alcanza (igual que antes, ganando
+experience).
+
+## Tipos de master skills
+
+Básicamente hay dos tipos de master skills en un skill tree:
+
+### Passive skills
+
+Cuando se aprenden, estas skills otorgan ciertos power-ups de forma pasiva. Por
+ejemplo, hay master skills para incrementar la máxima health o para aumentar el
+attack damage.
+
+### Active skills
+
+Al aprenderlas, estas skills aparecen en la skill list. La mayoría de este tipo
+reemplaza skills existentes. Las skills reemplazadas permanecen en segundo plano
+y no son visibles en el game client, pero su valor interno sigue aplicándose a
+la master skill. La master skill solo define cuánto damage o buff se agrega a la
+skill reemplazada.
+
+## Estructura del master skill tree
+
+El master skill tree consta de tres roots. Las skills se colocan en filas que
+definen el *rank* de una skill. Por defecto hay 5 ranks disponibles; sin embargo,
+el game client soporta hasta 9 ranks. Cada skill usualmente puede tener hasta 20
+levels, algunas solo 10; el client probablemente soporta más.
+
+Una skill puede depender de una skill del mismo rank o del rank previo del mismo
+root.
+
+Cada character class puede aprender diferentes skills, que a veces son exclusivas
+de la clase. Una skill puede estar disponible para múltiples character classes,
+pero la root y el rank de una skill siempre son los mismos para todas las
+character classes. La apariencia visual en el client puede diferir.
+
+## Requisitos para aprender una skill
+
+Para aprender una skill, el server (y el client) realiza las siguientes
+verificaciones:
+
+### Character class
+
+La skill debe estar definida para la clase del character.
+
+### Rank
+
+La skill debe estar en el primer rank o una skill del rank previo del mismo root
+debe estar al menos en level 10.
+
+### Required Skill
+
+Si la skill tiene required skills definidas (es opcional), estas skills deben
+estar al menos en level 10.
+
+## Client implementation
+
+Algunas notas breves sobre el client:
+
+* Existe un message para el master level, etc. (F3 50)
+ * Contiene health y mana. Si este packet no se envía, un master character
+ aparece sin health/mana.
+* Existe un message para las master skills aprendidas (F3 53)
+ * La información sobre cada skill contiene:
+ * Skill Number
+ * Skill Index
+ * Define dónde se ubica la skill en la interface del client
+ * Me pregunto por qué necesitan esto en el message, ya que el client ya
+ conoce el index de una skill
+ * Puede diferir entre character classes
+ * Current Level
+ * Valor de su efecto en el current level
+ * Valor de su efecto en el next level
+ * Incluso si no se aprendió ninguna skill, este message debe enviarse; de lo
+ contrario, puede contener la información master del master character jugado
+ anteriormente.
+
+## Server implementation
+
+### Adding Points
+
+* Para código, ver [AddMasterPointAction](https://github.com/MUnique/OpenMU/tree/master/src/GameLogic/PlayerActions/Character/AddMasterPointAction.cs).
+* Request Packet: [C1F352 - Add Master Skill Point](Packets/C1-F3-52-AddMasterSkillPoint_by-client.md)
+* Response Packet: [C1F352 - Master skill level update](Packets/C1-F3-52-MasterSkillLevelUpdate_by-server.md)
+
+### Sending Master Stats (F3 50)
+
+* Para código, ver [UpdateMasterStatsPlugIn](https://github.com/MUnique/OpenMU/tree/master/src/GameServer/RemoteView/Character/UpdateMasterStatsPlugIn.cs).
+* Request Packet: [C2F350 - Master Stats Update](Packets/C1-F3-50-MasterStatsUpdate_by-server.md)
+
+### Sending Master Skills (F3 53)
+
+* Para código, ver [UpdateMasterSkillsPlugIn](https://github.com/MUnique/OpenMU/tree/master/src/GameServer/RemoteView/Character/UpdateMasterSkillsPlugIn.cs).
+* Packet: [C2F353 - Master Skill List](Packets/C2-F3-53-MasterSkillList_by-server.md)
diff --git a/docs/Packets/C1-86-ItemCraftingResult_by-server.md b/docs/Packets/C1-86-ItemCraftingResult_by-server.md
index 3cd762f85..f4ecf55a0 100644
--- a/docs/Packets/C1-86-ItemCraftingResult_by-server.md
+++ b/docs/Packets/C1-86-ItemCraftingResult_by-server.md
@@ -16,7 +16,9 @@ The game client updates the UI to show the resulting item.
| 1 | 1 | Byte | | Packet header - length of the packet |
| 2 | 1 | Byte | 0x86 | Packet header - packet type identifier |
| 3 | 1 | CraftingResult | | Result |
-| 4 | | Binary | | ItemData |
+| 4 | 1 | Byte | | SuccessRate |
+| 5 | 1 | Byte | | BonusRate |
+| 6 | | Binary | | ItemData |
### CraftingResult Enum
diff --git a/docs/Readme.md b/docs/Readme.md
index 245b730ef..fc4247cf5 100644
--- a/docs/Readme.md
+++ b/docs/Readme.md
@@ -1,4 +1,9 @@
-# Documentation
+# Documentation / Documentación
+
+*Read this documentation in [English](#english) or [Español](#espanol).*
+
+
+## English
This directory should contain all the (technical) documentation of the OpenMU
project, including [packets descriptions](Packets/Readme.md), game mechanics
@@ -158,3 +163,169 @@ progress of the project. See also:
* [Startup](https://github.com/MUnique/OpenMU/tree/master/src/Startup): It's the
project for the executeable which puts every piece of the puzzle together
+
+
+## Español
+
+Este directorio debe contener toda la documentación técnica de OpenMU,
+incluyendo [descripciones de packets](Packets/Readme.md), mecánicas del
+game y arquitectura de software.
+
+## ¿Por qué no usar la wiki?
+
+Pensamos que gran parte de la documentación (especialmente la de packets)
+se basa en el código. Por eso tiene sentido que toda la documentación
+técnica esté disponible junto al código, sin necesidad de la wiki.
+
+Sin embargo, documentación como la guía de usuario para poner en marcha el
+proyecto podría estar en la wiki.
+
+## Arquitectura del Game Server
+
+Descargo de responsabilidad (por [sven-n](https://github.com/sven-n)):
+*No es una arquitectura perfecta, si es que existe. Sin embargo, tiene
+sentido para este propósito como desarrollador de aplicaciones de negocio.
+Intenté hacerla flexible y espero que no sea demasiado complicada.*
+
+Para ver el panorama general, puedes mirar el
+[architecture overview](architecture%20overview.png).
+
+Existen interfaces para la interoperabilidad entre los diferentes
+"servers" o subsistemas en
+[MUnique.OpenMU.Interfaces](https://github.com/MUnique/OpenMU/tree/master/src/Interfaces).
+
+### Comunicación entre el game client y el server
+
+La comunicación de red entre el game client y el game server se realiza a
+través de la [Connection class](https://github.com/MUnique/OpenMU/tree/master/src/Network/Connection.cs).
+MUnique.OpenMU.Network contiene todo lo necesario para conectar desde y
+hacia un game server usando el protocolo de red de MU Online. También
+contiene las estructuras de mensaje en
+MUnique.OpenMU.Network.Packets.
+
+#### Client -> Server
+
+Cuando se reciben datos del game client, se redirigen a los packet
+handlers ubicados en el espacio de nombres
+MUnique.OpenMU.GameServer.MessageHandler. Cada handler implementa un
+IPacketHandlerPlugIn. Estos packet handlers analizan los data packets y
+luego llaman a las player actions en
+MUnique.OpenMU.GameLogic.PlayerActions, las cuales no deberían tener
+conocimiento de la estructura del packet ni del medio de comunicación.
+
+#### Server -> Client
+
+El envío de datos al game client se realiza mediante views
+(MUnique.OpenMU.GameServer.RemoteView). Estas views utilizan la Connection
+class para enviar los datos en el protocolo especificado. GameLogic no
+conoce este protocolo y solo trabaja con los
+[view interface plugins](https://github.com/MUnique/OpenMU/tree/master/src/GameLogic/Views/IViewPlugIn.cs).
+
+#### Beneficios de esta arquitectura
+
+Como puedes ver, GameLogic en sí no sabe cómo se desencadenan las player
+actions ni cómo luce la "view". En lugar de trabajar con la red, podría
+haber una implementación de
+[view plugins](https://github.com/MUnique/OpenMU/tree/master/src/GameLogic/Views/IViewPlugIn.cs)
+que literalmente sea una interfaz gráfica. En vez de llamar las player
+actions mediante packet handler plugins, una interfaz de usuario podría
+llamarlas. Así, este proyecto podría ser la base de un game client (no MU)
+que también pueda soportar multiplayer y co-op con los componentes de
+server existentes.
+
+Todos los plugins son configurables desde el AdminPanel. Pueden activarse
+o desactivarse para reemplazarlos por versiones extendidas o modificadas.
+También es posible ofrecer diferentes protocolos para trabajar en el mismo
+game world implementando múltiples views y packet handlers con atributos
+de versiones de client diferentes. Cada game server puede tener varios
+tcp listeners que se vinculan a puertos tcp separados para distintas
+versiones de client.
+
+### Acceso a datos
+
+El patrón de acceso es principalmente el siguiente:
+
+* Al iniciar el server, se carga la game configuration
+* Cuando un game client inicia sesión, se carga su account
+* Durante el game, los datos del account se guardan en puntos
+ específicos y en un intervalo de tiempo
+
+#### Objetivo de diseño
+
+Debería ser posible soportar múltiples bases de datos diferentes, incluso
+NoSQL, sin cambiar la game logic. Para ello no usamos SQL ni código
+específico de la base de datos en la game logic. Los patrones de acceso
+deben tenerlo en cuenta. En lugar de muchas llamadas individuales a
+diferentes repositories en la game logic, debería hacerse una gran
+llamada.
+
+Por ejemplo, si queremos usar una base de datos basada en documentos, un
+account podría ser un documento y la game configuration completa otro.
+
+#### Abstracciones
+
+Para lograr estos objetivos de diseño, la game logic (y otras partes) usan
+abstracciones, [Repositories](https://martinfowler.com/eaaCatalog/repository.html),
+para acceder a datos. Estas abstracciones se encuentran en el namespace
+MUnique.OpenMU.Persistence.
+
+Utilizamos un enfoque basado en contextos para acceder a los datos, por
+ejemplo, la
+[GameConfiguration](https://github.com/MUnique/OpenMU/tree/master/src/DataModel/Configuration/GameConfiguration.cs)
+se carga a través del
+[GameConfigurationRepository](https://github.com/MUnique/OpenMU/tree/master/src/Persistence/EntityFramework/GameConfigurationRepository.cs)
+mientras se "usa" un
+[context](https://github.com/MUnique/OpenMU/tree/master/src/Persistence/IContext.cs),
+y cada player conectado utiliza su propio
+[player context](https://github.com/MUnique/OpenMU/tree/master/src/Persistence/IPlayerContext.cs)
+para cargar su
+[Account](https://github.com/MUnique/OpenMU/tree/master/src/DataModel/Entities/Account.cs).
+Al guardar un account, guardamos su context, que se encarga de aplicar los
+cambios requeridos en la base de datos. Cuando se accede o se crean nuevos
+objetos persistentes, el
+[context](https://github.com/MUnique/OpenMU/tree/master/src/Persistence/IContext.cs)
+debe estar "en uso" en el hilo actual, porque la implementación del
+context puede necesitar rastrear estos objetos. Se encarga de muchas
+cosas, por ejemplo crear nuevos objetos. Los contexts pueden crearse con
+el
+[PersistenceContextProvider](https://github.com/MUnique/OpenMU/tree/master/src/Persistence/IPersistenceContextProvider.cs).
+
+#### Implementación actual y base de datos soportada
+
+Por el momento, la capa de persistencia está implementada por
+[MUnique.OpenMU.Persistence.EntityFramework](../src/Persistence/EntityFramework/Readme.md)
+que utiliza [Entity Framework Core](https://github.com/aspnet/EntityFrameworkCore)
+y [PostgreSQL](https://www.postgresql.org/) como base de datos.
+
+#### Futuro
+
+Debido a que el data model es bastante complicado (lo cual es necesario si
+la configuration debe ser tan flexible), un modelo relacional completo en
+una base de datos probablemente no sea lo mejor en términos de
+performance. Actualmente usamos una aproximación para cargar la game
+configuration o el account con una consulta grande y generada
+dinámicamente que nos da los datos como json. Sorprendentemente es rápido,
+ya que la consulta para cargar la compleja game configuration termina en
+aproximadamente 1.5 segundos.
+
+Si hubiera un problema en el futuro, podríamos mezclar tablas relacionales
+con columnas json o cambiar completamente a una base de datos basada en
+documentos (por ejemplo, RavenDB).
+
+### Más información
+
+* [Packets](Packets/Readme.md): Información sobre las estructuras de packet
+* [Master Skill System](MasterSystem.md): Descripción del master skill system
+* [GameMap](GameMap.md): Descripción de la implementación de GameMap
+* [Progress](Progress.md): Información sobre el progreso de implementación
+ de las features del proyecto. Ver también:
+ * [Normal skill progress](https://github.com/MUnique/OpenMU/projects/9)
+ * [Master skill progress](https://github.com/MUnique/OpenMU/projects/10)
+* [Admin Panel](https://github.com/MUnique/OpenMU/tree/master/src/Web/AdminPanel):
+ La user inferface del server
+* [Attribute System](https://github.com/MUnique/OpenMU/tree/master/src/AttributeSystem):
+ El cálculo de damage y los atributos de player se basan en él
+* [Network](https://github.com/MUnique/OpenMU/tree/master/src/Network): Sobre la
+ comunicación de red
+* [Startup](https://github.com/MUnique/OpenMU/tree/master/src/Startup): El
+ proyecto para el ejecutable que junta todas las piezas del rompecabezas
diff --git a/monsters_server.csv b/monsters_server.csv
new file mode 100644
index 000000000..e54b75686
--- /dev/null
+++ b/monsters_server.csv
@@ -0,0 +1,455 @@
+"Number","Name"
+"5","Hell Hound"
+"8","Poison Bull"
+"9","Thunder Lich"
+"10","Dark Knight"
+"11","Ghost"
+"12","Larva"
+"13","Hell Spider"
+"15","Skeleton Archer"
+"16","Elite Skeleton"
+"17","Cyclops"
+"18","Gorgon"
+"19","Yeti"
+"20","Elite Yeti"
+"21","Assassin"
+"22","Ice Monster"
+"23","Hommerd"
+"24","Worm"
+"25","Ice Queen"
+"26","Goblin"
+"27","Chain Scorpion"
+"28","Beetle Monster"
+"29","Hunter"
+"30","Forest Monster"
+"31","Agon"
+"32","Stone Golem"
+"33","Elite Goblin"
+"34","Cursed Wizard"
+"35","Death Gorgon"
+"36","Shadow"
+"37","Devil"
+"38","Balrog"
+"39","Poison Shadow"
+"40","Death Knight"
+"41","Death Cow"
+"43","Golden Budge Dragon"
+"44","Red Dragon"
+"45","Bahamut"
+"46","Vepar"
+"47","Valkyrie"
+"48","Lizard King"
+"49","Hydra"
+"50","Sea Worm"
+"51","Great Bahamut"
+"52","Silver Valkyrie"
+"53","Golden Titan"
+"54","Golden Soldier"
+"57","Iron Wheel"
+"58","Tantallos"
+"59","Zaikan"
+"60","Bloody Wolf"
+"61","Beam Knight"
+"62","Mutant"
+"63","Death Beam Knight"
+"64","Orc Archer"
+"65","Elite Orc"
+"66","Cursed King"
+"67","Metal Balrog"
+"69","Alquamos"
+"70","Queen Rainer"
+"71","Mega Crust"
+"72","Phantom Knight"
+"73","Drakan"
+"74","Alpha Crust"
+"75","Great Drakan"
+"76","Dark Phoenix Shield"
+"77","Dark Phoenix"
+"78","Golden Goblin"
+"79","Golden Dragon"
+"80","Golden Lizard King"
+"81","Golden Vepar"
+"82","Golden Tantallos"
+"83","Golden Wheel"
+"84","Chief Skeleton Warrior 1"
+"85","Chief Skeleton Archer 1"
+"86","Dark Skull Soldier 1"
+"87","Giant Ogre 1"
+"88","Red Skeleton Knight 1"
+"89","Magic Skeleton 1"
+"90","Chief Skeleton Warrior 2"
+"91","Chief Skeleton Archer 2"
+"92","Dark Skull Soldier 2"
+"93","Giant Ogre 2"
+"94","Red Skeleton Knight 2"
+"95","Magic Skeleton 2"
+"96","Chief Skeleton Warrior 3"
+"97","Chief Skeleton Archer 3"
+"98","Dark Skull Soldier 3"
+"99","Giant Ogre 3"
+"105","Canon Trap"
+"106","Laser Trap"
+"111","Red Skeleton Knight 3"
+"112","Magic Skeleton 3"
+"113","Chief Skeleton Warrior 4"
+"114","Chief Skeleton Archer 4"
+"115","Dark Skull Soldier 4"
+"116","Giant Ogre 4"
+"117","Red Skeleton Knight 4"
+"118","Magic Skeleton 4"
+"119","Chief Skeleton Warrior 5"
+"120","Chief Skeleton Archer 5"
+"121","Dark Skull Soldier 5"
+"122","Giant Ogre 5"
+"123","Red Skeleton Knight 5"
+"124","Magic Skeleton 5"
+"125","Chief Skeleton Warrior 6"
+"126","Chief Skeleton Archer 6"
+"127","Dark Skull Soldier 6"
+"128","Giant Ogre 6"
+"129","Red Skeleton Knight 6"
+"130","Magic Skeleton 6"
+"131","Castle Gate"
+"132","Statue of Saint"
+"133","Statue of Saint"
+"134","Statue of Saint"
+"135","White Wizard"
+"138","Chief Skeleton Warrior 7"
+"139","Chief Skeleton Archer 7"
+"140","Dark Skull Soldier 7"
+"141","Giant Ogre 7"
+"142","Red Skeleton Knight 7"
+"143","Magic Skeleton 7"
+"144","Death Angel 1"
+"145","Death Centurion 1"
+"146","Blood Soldier 1"
+"147","Aegis 1"
+"148","Rogue Centurion 1"
+"149","Necron 1"
+"150","Bali"
+"151","Soldier"
+"152","Gate to Kalima 1 of {0}"
+"153","Gate to Kalima 2 of {0}"
+"154","Gate to Kalima 3 of {0}"
+"155","Gate to Kalima 4 of {0}"
+"156","Gate to Kalima 5 of {0}"
+"157","Gate to Kalima 6 of {0}"
+"158","Gate to Kalima 7 of {0}"
+"160","Schriker 1"
+"161","Illusion of Kundun 1"
+"162","Chaos Castle 1"
+"163","Chaos Castle 2"
+"164","Chaos Castle 3"
+"165","Chaos Castle 4"
+"166","Chaos Castle 5"
+"167","Chaos Castle 6"
+"168","Chaos Castle 7"
+"169","Chaos Castle 8"
+"170","Chaos Castle 9"
+"171","Chaos Castle 10"
+"172","Chaos Castle 11"
+"173","Chaos Castle 12"
+"174","Death Angel 2"
+"175","Death Centurion 2"
+"176","Blood Soldier 2"
+"177","Aegis 2"
+"178","Rogue Centurion 2"
+"179","Necron 2"
+"180","Schriker 2"
+"181","Illusion of Kundun 2"
+"182","Death Angel 3"
+"183","Death Centurion 3"
+"184","Blood Soldier 3"
+"185","Aegis 3"
+"186","Rogue Centurion 3"
+"187","Necron 3"
+"188","Schriker 3"
+"189","Illusion of Kundun 3"
+"190","Death Angel 4"
+"191","Death Centurion 4"
+"192","Blood Soldier 4"
+"193","Aegis 4"
+"194","Rogue Centurion 4"
+"195","Necron 4"
+"196","Schriker 4"
+"197","Illusion of Kundun 4"
+"200","Soccerball"
+"204","Wolf Status"
+"205","Wolf Altar1"
+"206","Wolf Altar2"
+"207","Wolf Altar3"
+"208","Wolf Altar4"
+"209","Wolf Altar5"
+"215","Shield"
+"216","Crown"
+"217","Crown Switch1"
+"218","Crown Switch2"
+"219","Castle Gate Switch"
+"220","Guard"
+"221","Slingshot Attack"
+"222","Slingshot Defense"
+"223","Senior"
+"224","Guardsman"
+"226","Pet Trainer"
+"229","Marlon"
+"230","Alex"
+"231","Thompson the Merchant"
+"232","Archangel"
+"233","Messenger of Arch."
+"235","Sevina the Priestess"
+"236","Golden Archer"
+"237","Charon"
+"238","Chaos Goblin"
+"239","Arena Guard"
+"240","Baz The Vault Keeper"
+"241","Guild Master"
+"242","Elf Lala"
+"243","Eo the Craftsman"
+"244","Caren the Barmaid"
+"245","Izabel The Wizard"
+"246","Zienna The Weapons Merchant"
+"247","Crossbow Guard"
+"248","Wandering Merchant Martin"
+"249","Berdysh Guard"
+"250","Wandering Merchant Harold"
+"251","Hanzo The Blacksmith"
+"253","Potion Girl Amy"
+"254","Pasi The Mage"
+"255","Lumen the Barmaid"
+"256","Lahap"
+"257","Elf Soldier"
+"259","Oracle Layla"
+"260","Death Angel 5"
+"261","Death Centurion 5"
+"262","Blood Soldier 5"
+"263","Aegis 5"
+"264","Rogue Centurion 5"
+"265","Necron 5"
+"266","Schriker 5"
+"267","Illusion of Kundun 5"
+"268","Death Angel 6"
+"269","Death Centurion 6"
+"270","Blood Soldier 6"
+"271","Aegis 6"
+"272","Rogue Centurion 6"
+"273","Necron 6"
+"274","Schriker 6"
+"275","Illusion of Kundun 7"
+"277","Castle Gate1"
+"278","Life Stone"
+"283","Guardian Statue"
+"285","Guardian"
+"286","Battle Guard1"
+"287","Battle Guard2"
+"288","Canon Tower"
+"290","Lizard Warrior"
+"291","Fire Golem"
+"292","Queen Bee"
+"293","Poison Golem"
+"294","Axe Warrior"
+"295","Erohim"
+"304","Witch Queen"
+"305","Blue Golem"
+"306","Death Rider"
+"307","Forest Orc"
+"308","Death Tree"
+"309","Hell Maine"
+"310","Hammer Scout"
+"311","Lance Scout"
+"312","Bow Scout"
+"313","Werewolf"
+"314","Scout(Hero)"
+"315","Werewolf(Hero)"
+"316","Balram"
+"317","Soram"
+"331","Aegis 7"
+"332","Rogue Centurion 7"
+"333","Blood Soldier 7"
+"334","Death Angel 7"
+"335","Necron 7"
+"336","Death Centurion 7"
+"337","Schriker 7"
+"338","Illusion of Kundun 6"
+"350","Berserker"
+"351","Splinter Wolf"
+"352","Iron Rider"
+"353","Satyros"
+"354","Blade Hunter"
+"355","Kentauros"
+"356","Gigantis"
+"357","Genocider"
+"358","Persona"
+"359","Twin Tale"
+"360","Dreadfear"
+"367","Gateway Machine"
+"368","Elphis"
+"369","Osbourne"
+"370","Jerridon"
+"371","Leo The Helper"
+"372","Elite Skill Soldier"
+"375","Chaos Card Master"
+"376","Pamela the Supplier"
+"377","Angela the Supplier"
+"378","GameMaster"
+"379","Fireworks Girl"
+"380","Stone Statue"
+"381","MU Allies General"
+"382","Illusion Elder"
+"383","Alliance Item Storage"
+"384","Illusion Item Storage"
+"385","Mirage"
+"404","MU Allies"
+"405","Illusion Sorcerer"
+"406","Priest Devin"
+"407","Werewolf Quarrel"
+"408","Gatekeeper"
+"409","Balram (Trainee Soldier)"
+"410","Death Spirit (Trainee Soldier)"
+"411","Soram (Trainee Soldier)"
+"412","Dark Elf (Trainee Soldier)"
+"415","Silvia"
+"416","Rhea"
+"417","Marce"
+"418","Strange Rabbit"
+"419","Polluted Butterfly"
+"420","Hideous Rabbit"
+"421","Werewolf"
+"422","Cursed Lich"
+"423","Totem Golem"
+"424","Grizzly"
+"425","Captain Grizzly"
+"426","Chaos Castle 13"
+"427","Chaos Castle 14"
+"428","Chief Skeleton Warrior 8"
+"429","Chief Skeleton Archer 8"
+"430","Dark Skull Soldier 8"
+"431","Giant Ogre 8"
+"432","Red Skeleton Knight 8"
+"433","Magic Skeleton 8"
+"434","Gigantis"
+"435","Berserk"
+"436","Balram (Trainee)"
+"437","Soram (Trainee)"
+"438","Persona"
+"439","Dreadfear"
+"440","Dark_Elf"
+"441","Sapi-Unus"
+"442","Sapi-Duo"
+"443","Sapi-Tres"
+"444","Shadow Pawn"
+"445","Shadow Knight"
+"446","Shadow Look"
+"447","Thunder Napin"
+"448","Ghost Napin"
+"449","Blaze Napin"
+"450","Cherry Blossom Spirit"
+"451","Cherry Blossom Tree"
+"452","Seed Master"
+"453","Seed Researcher"
+"454","Ice Walker"
+"455","Giant Mammoth"
+"456","Ice Giant"
+"457","Coolutin"
+"458","Iron Knight"
+"459","Selupan"
+"460","Spider Eggs 1"
+"461","Spider Eggs 2"
+"462","Spider Eggs 3"
+"467","Snowman"
+"468","Little Santa Yellow"
+"469","Little Santa Green"
+"470","Little Santa Red"
+"471","Little Santa Blue"
+"472","Little Santa White"
+"473","Little Santa Black"
+"474","Little Santa Orange"
+"475","Little Santa Pink"
+"476","Cursed Santa"
+"477","Transformed Snowman"
+"478","Delgado - Lucky Coins"
+"479","Gatekeeper Titus"
+"480","Zombie Fighter"
+"481","Zombie Fighter"
+"482","Resurrected Gladiator"
+"483","Resurrected Gladiator"
+"484","Ash Slaughterer"
+"485","Ash Slaughterer"
+"486","Blood Assassin"
+"487","Cruel Blood Assassin"
+"488","Cruel Blood Assassin"
+"489","Burning Lava Giant"
+"490","Ruthless Lava Giant"
+"491","Ruthless Lava Giant"
+"492","Moss The Merchant"
+"504","Gayion The Gladiator"
+"505","Jerry"
+"506","Raymond"
+"507","Lucas"
+"508","Fred"
+"509","Hammerize"
+"510","Dual Berserker"
+"511","Devil Lord"
+"512","Quarter Master"
+"513","Combat Instructor"
+"514","Aticle's Head"
+"515","Dark Ghost"
+"516","Banshee"
+"517","Head Mounter"
+"518","Defender"
+"519","Forsaker"
+"520","Ocelot the Lord"
+"521","Eric the Guard"
+"522","Adviser Jerinteu"
+"523","Trap"
+"524","Evil Gate"
+"525","Lion Gate"
+"526","Statue"
+"527","Star Gate"
+"528","Rush Gate"
+"540","Lugard"
+"541","Compensation Box"
+"542","Golden Compensation Box"
+"543","Gens Duprian"
+"544","Gens Vanert"
+"545","Christine the General Goods Merchant"
+"546","Jeweler Raul"
+"547","Market Union Member Julia"
+"549","Bloody Orc"
+"550","Bloody Death Rider"
+"551","Bloody Golem"
+"552","Bloody Witch Queen"
+"553","Berserker Warrior"
+"554","Kentauros Warrior"
+"555","Gigantis Warrior"
+"556","Genocider Warrior"
+"557","Sapi Queen"
+"558","Ice Napin"
+"559","Shadow Master"
+"562","Dark Mammoth"
+"563","Dark Giant"
+"564","Dark Coolutin"
+"565","Dark Iron Knight"
+"566","Mercenary Guild Felicia"
+"568","Wandering Merchant Zyro"
+"569","Venomous Chain Scorpion"
+"570","Bone Scorpion"
+"571","Orcus"
+"572","Gollock"
+"573","Crypta"
+"574","Crypos"
+"575","Condra"
+"576","Narcondra"
+"577","Leina the General Goods Merchant"
+"578","Weapons Merchant Bolo"
+"579","David"
+"658","Cursed Statue"
+"659","Captured Stone Statue (1)"
+"660","Captured Stone Statue (2)"
+"661","Captured Stone Statue (3)"
+"662","Captured Stone Statue (4)"
+"663","Captured Stone Statue (5)"
+"664","Captured Stone Statue (6)"
+"665","Captured Stone Statue (7)"
+"666","Captured Stone Statue (8)"
+"667","Captured Stone Statue (9)"
+"668","Captured Stone Statue (10)"
diff --git a/src/AttributeSystem/StatAttribute.cs b/src/AttributeSystem/StatAttribute.cs
index 4d53cdbff..b9b9accb4 100644
--- a/src/AttributeSystem/StatAttribute.cs
+++ b/src/AttributeSystem/StatAttribute.cs
@@ -40,9 +40,9 @@ public StatAttribute(AttributeDefinition definition, float baseValue)
{
get
{
- if (this.Definition.MaximumValue.HasValue)
+ if (this.Definition?.MaximumValue.HasValue == true)
{
- return Math.Min(this.Definition.MaximumValue.Value, this._statValue);
+ return Math.Min(this.Definition!.MaximumValue!.Value, this._statValue);
}
return this._statValue;
@@ -60,4 +60,4 @@ public StatAttribute(AttributeDefinition definition, float baseValue)
///
protected override float ValueGetter => this._statValue;
-}
\ No newline at end of file
+}
diff --git a/src/ConnectServer/PacketHandler/ClientPacketHandler.cs b/src/ConnectServer/PacketHandler/ClientPacketHandler.cs
index d95116e4d..9ee60bd86 100644
--- a/src/ConnectServer/PacketHandler/ClientPacketHandler.cs
+++ b/src/ConnectServer/PacketHandler/ClientPacketHandler.cs
@@ -8,6 +8,7 @@ namespace MUnique.OpenMU.ConnectServer.PacketHandler;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Network;
using IConnectServer = MUnique.OpenMU.ConnectServer.IConnectServer;
///
@@ -47,8 +48,25 @@ public async ValueTask HandlePacketAsync(Client client, Memory packet)
return;
}
- var packetType = packet.Span[2];
- if (this._packetHandlers.TryGetValue(packetType, out var packetHandler))
+ var typeIndex = ArrayExtensions.GetPacketHeaderSize(packet.Span);
+ if (typeIndex == 0 || packet.Length <= typeIndex)
+ {
+ await this.DisconnectClientUnknownPacketAsync(client, packet).ConfigureAwait(false);
+ return;
+ }
+
+ var packetType = packet.Span[typeIndex];
+ this._packetHandlers.TryGetValue(packetType, out var packetHandler);
+ if (packetHandler is null)
+ {
+ var normalizedType = ArrayExtensions.NormalizePacketType(packet.Span[0], packetType);
+ if (normalizedType != packetType)
+ {
+ this._packetHandlers.TryGetValue(normalizedType, out packetHandler);
+ }
+ }
+
+ if (packetHandler is not null)
{
await packetHandler.HandlePacketAsync(client, packet).ConfigureAwait(false);
}
@@ -79,4 +97,4 @@ private async ValueTask DisconnectClientUnknownPacketAsync(Client client, Memory
this._logger.LogInformation("Client {0}:{1} will be disconnected because it sent an unknown packet: {2}", client.Address, client.Port, packet.ToArray().ToHexString());
await client.Connection.DisconnectAsync().ConfigureAwait(false);
}
-}
\ No newline at end of file
+}
diff --git a/src/ConnectServer/PacketHandler/ServerListHandler.cs b/src/ConnectServer/PacketHandler/ServerListHandler.cs
index 9184f3300..9a90e0bc3 100644
--- a/src/ConnectServer/PacketHandler/ServerListHandler.cs
+++ b/src/ConnectServer/PacketHandler/ServerListHandler.cs
@@ -6,6 +6,8 @@ namespace MUnique.OpenMU.ConnectServer.PacketHandler;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Network;
+using MUnique.OpenMU.Network.Xor;
using IConnectServer = MUnique.OpenMU.ConnectServer.IConnectServer;
///
@@ -36,10 +38,33 @@ public ServerListHandler(IConnectServer connectServer, ILoggerFactory loggerFact
///
public async ValueTask HandlePacketAsync(Client client, Memory packet)
{
- var packetSubType = packet.Span[3];
+ var headerSize = ArrayExtensions.GetPacketHeaderSize(packet.Span);
+ if (headerSize == 0 || packet.Length <= headerSize + 1)
+ {
+ if (this._connectServerSettings.DisconnectOnUnknownPacket)
+ {
+ this._logger.LogInformation("Client {0}:{1} will be disconnected because it sent an unknown packet: {2}", client.Address, client.Port, packet.ToArray().ToHexString());
+ await client.Connection.DisconnectAsync().ConfigureAwait(false);
+ }
+
+ return;
+ }
+
+ var subTypeIndex = headerSize + 1;
+ var packetSubType = packet.Span[subTypeIndex];
if (this._packetHandlers.TryGetValue(packetSubType, out var packetHandler))
{
await packetHandler.HandlePacketAsync(client, packet).ConfigureAwait(false);
+ return;
+ }
+
+ if (TryGetXorDecryptedPacket(packet.Span, out var decryptedPacket, out var decryptedSubType))
+ {
+ if (this._packetHandlers.TryGetValue(decryptedSubType, out var decryptedHandler))
+ {
+ await decryptedHandler.HandlePacketAsync(client, decryptedPacket).ConfigureAwait(false);
+ return;
+ }
}
else if (this._connectServerSettings.DisconnectOnUnknownPacket)
{
@@ -51,4 +76,37 @@ public async ValueTask HandlePacketAsync(Client client, Memory packet)
// do nothing
}
}
-}
\ No newline at end of file
+
+ private static bool TryGetXorDecryptedPacket(ReadOnlySpan packet, out Memory decryptedPacket, out byte subType)
+ {
+ decryptedPacket = default;
+ subType = 0;
+
+ if (packet.IsEmpty)
+ {
+ return false;
+ }
+
+ var headerSize = ArrayExtensions.GetPacketHeaderSize(packet[0]);
+ if (headerSize == 0 || packet.Length <= headerSize + 1)
+ {
+ return false;
+ }
+
+ var buffer = packet.ToArray();
+ for (var i = buffer.Length - 1; i > headerSize; i--)
+ {
+ buffer[i] = (byte)(buffer[i] ^ buffer[i - 1] ^ DefaultKeys.Xor32Key[i % 32]);
+ }
+
+ var subTypeIndex = headerSize + 1;
+ if (subTypeIndex >= buffer.Length)
+ {
+ return false;
+ }
+
+ subType = buffer[subTypeIndex];
+ decryptedPacket = buffer;
+ return true;
+ }
+}
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 64aba1763..b07f17306 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -12,9 +12,17 @@
runtime; build; native; contentfiles; analyzers
-
+
-
+
+
+ false
+ false
+
+ $(NoWarn);CS1591;CS0162;CS0168;CS1711;CS1723;CS1696;CS0067
+
+
+
@@ -36,4 +44,4 @@
-
\ No newline at end of file
+
diff --git a/src/GameLogic/AttackableExtensions.cs b/src/GameLogic/AttackableExtensions.cs
index 63684c730..d21e687e1 100644
--- a/src/GameLogic/AttackableExtensions.cs
+++ b/src/GameLogic/AttackableExtensions.cs
@@ -19,6 +19,8 @@ namespace MUnique.OpenMU.GameLogic;
///
public static class AttackableExtensions
{
+ private const double MaximumElementalResistance = 255.0;
+
private static readonly IDictionary ReductionModifiers =
new Dictionary
{
@@ -459,6 +461,11 @@ public static async ValueTask TryApplyElementalEffectsAsync(this IAttackab
return applied;
}
+ private static double NormalizeElementalResistance(double resistance)
+ {
+ return Math.Min(1.0, Math.Max(0.0, resistance / MaximumElementalResistance));
+ }
+
///
/// Applies the ammunition consumption.
///
@@ -563,6 +570,11 @@ public static int GetRequiredValue(this IAttacker attacker, AttributeRequirement
/// The calculated base experience.
public static double CalculateBaseExperience(this IAttackable killedObject, float killerLevel)
{
+ // Summoned monsters should not yield experience.
+ if (killedObject is Monster { SummonedBy: { } })
+ {
+ return 0;
+ }
var targetLevel = killedObject.Attributes[Stats.Level];
var tempExperience = (targetLevel + 25) * targetLevel / 3.0;
@@ -918,4 +930,4 @@ private static int GetMasterSkillTreeMasteryPvpDamageBonus(IAttacker attacker)
return 0;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/Attributes/MonsterAttributeHolder.cs b/src/GameLogic/Attributes/MonsterAttributeHolder.cs
index 7eb2ad332..0f394ebc0 100644
--- a/src/GameLogic/Attributes/MonsterAttributeHolder.cs
+++ b/src/GameLogic/Attributes/MonsterAttributeHolder.cs
@@ -17,8 +17,14 @@ public class MonsterAttributeHolder : IAttributeSystem
new Dictionary>
{
{ Stats.CurrentHealth, m => m.Health },
- { Stats.DefensePvm, m => m.Attributes.GetValueOfAttribute(Stats.DefenseBase) + ((m as Monster)?.SummonedBy?.Attributes?[Stats.SummonedMonsterDefenseIncrease] ?? 0) },
- { Stats.DefensePvp, m => m.Attributes.GetValueOfAttribute(Stats.DefenseBase) + ((m as Monster)?.SummonedBy?.Attributes?[Stats.SummonedMonsterDefenseIncrease] ?? 0) },
+ { Stats.DefensePvm, m =>
+ m.Attributes.GetValueOfAttribute(Stats.DefenseBase)
+ + m.Attributes.GetValueOfAttribute(Stats.DefenseFinal)
+ + ((m as Monster)?.SummonedBy?.Attributes?[Stats.SummonedMonsterDefenseIncrease] ?? 0) },
+ { Stats.DefensePvp, m =>
+ m.Attributes.GetValueOfAttribute(Stats.DefenseBase)
+ + m.Attributes.GetValueOfAttribute(Stats.DefenseFinal)
+ + ((m as Monster)?.SummonedBy?.Attributes?[Stats.SummonedMonsterDefenseIncrease] ?? 0) },
{ Stats.DamageReceiveDecrement, m => 1.0f },
{ Stats.AttackDamageIncrease, m => 1.0f },
{ Stats.ShieldBypassChance, m => 1.0f },
@@ -30,7 +36,8 @@ public class MonsterAttributeHolder : IAttributeSystem
{ Stats.CurrentHealth, (m, v) => m.Health = (int)v },
};
- private static readonly ConcurrentDictionary> MonsterStatAttributesCache = new();
+ // Avoid caching by MonsterDefinition equality (which is based on Id) because cloned definitions
+ // for summons may share the same Id but have different attribute values. We resolve per-instance.
private readonly AttackableNpcBase _monster;
@@ -159,9 +166,11 @@ public IElement GetOrCreateAttribute(AttributeDefinition attributeDefinition)
private static IDictionary GetStatAttributeOfMonster(MonsterDefinition monsterDefinition)
{
- return MonsterStatAttributesCache.GetOrAdd(monsterDefinition, monsterDef => monsterDef.Attributes.ToDictionary(
- m => m.AttributeDefinition ?? throw Error.NotInitializedProperty(m, nameof(m.AttributeDefinition)),
- m => m.Value));
+ // Build a fresh map for this concrete instance to respect any runtime-adjusted values
+ // (e.g. cloned summon definitions with modified stats).
+ return monsterDefinition.Attributes.ToDictionary(
+ m => m.AttributeDefinition ?? throw Error.NotInitializedProperty(m, nameof(m.AttributeDefinition)),
+ m => m.Value);
}
private IDictionary GetAttributeDictionary()
@@ -178,4 +187,4 @@ private IDictionary GetAttributeDicti
return attributes;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/CharacterExtensions.cs b/src/GameLogic/CharacterExtensions.cs
index e22fd84a2..e1d3da9c1 100644
--- a/src/GameLogic/CharacterExtensions.cs
+++ b/src/GameLogic/CharacterExtensions.cs
@@ -132,11 +132,25 @@ public static int GetEffectiveMoveLevelRequirement(this Character character, int
/// The quest drop item groups of a character.
public static IEnumerable GetQuestDropItemGroups(this Character character)
{
- return character.QuestStates?
+ var requirements = character.QuestStates?
.SelectMany(q => q.ActiveQuest?.RequiredItems ?? Enumerable.Empty())
- .Select(i => i.DropItemGroup)
- .WhereNotNull()
- ?? Enumerable.Empty();
+ ?? Enumerable.Empty();
+
+ return requirements
+ .Where(requirement => !HasRequiredItem(character, requirement))
+ .Select(requirement => requirement.DropItemGroup)
+ .WhereNotNull();
+ }
+
+ private static bool HasRequiredItem(Character character, QuestItemRequirement requirement)
+ {
+ if (requirement.Item is null || character.Inventory?.Items is null)
+ {
+ return false;
+ }
+
+ var count = character.Inventory.Items.Count(item => item.Definition == requirement.Item);
+ return count >= requirement.MinimumNumber;
}
private static IEnumerable GetFruitPoints(int divisor)
@@ -152,4 +166,4 @@ private static IEnumerable GetFruitPoints(int divisor)
yield return (ushort)current;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/Features/ZyroExpansionFeaturePlugIn.cs b/src/GameLogic/Features/ZyroExpansionFeaturePlugIn.cs
new file mode 100644
index 000000000..14929215d
--- /dev/null
+++ b/src/GameLogic/Features/ZyroExpansionFeaturePlugIn.cs
@@ -0,0 +1,63 @@
+// Copyright (c) MUnique. Licensed under the MIT license.
+
+namespace MUnique.OpenMU.GameLogic.Features;
+
+using System.ComponentModel.DataAnnotations;
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Feature configuration for Zyro (Wandering Merchant) expansion quests.
+/// Allows configuring the required reset counts per quest.
+///
+[PlugIn("Zyro Expansion Feature", "Configures reset requirements for Zyro expansion quests.")]
+[Guid("A3D031D4-282A-4C8C-9C1E-51C8C2E83E7B")]
+public class ZyroExpansionFeaturePlugIn : IFeaturePlugIn,
+ ISupportCustomConfiguration,
+ ISupportDefaultCustomConfiguration
+{
+ ///
+ /// Gets or sets the configuration.
+ ///
+ public ZyroExpansionConfiguration? Configuration { get; set; }
+
+ ///
+ public object CreateDefaultConfig() => new ZyroExpansionConfiguration
+ {
+ QuestGroup = 200,
+ ResetsRequiredForVault = 5,
+ ResetsRequiredForInventory1 = 10,
+ ResetsRequiredForInventory2 = 15,
+ };
+}
+
+///
+/// Configuration for .
+///
+public class ZyroExpansionConfiguration
+{
+ ///
+ /// Gets or sets the quest group id which is used for Zyro expansion quests.
+ ///
+ [Display(Name = "Quest Group")]
+ public short QuestGroup { get; set; }
+
+ ///
+ /// Gets or sets the reset requirement for the vault expansion quest.
+ ///
+ [Display(Name = "Resets for Vault Expansion")]
+ public int ResetsRequiredForVault { get; set; }
+
+ ///
+ /// Gets or sets the reset requirement for the first inventory expansion quest.
+ ///
+ [Display(Name = "Resets for Inventory Expansion #1")]
+ public int ResetsRequiredForInventory1 { get; set; }
+
+ ///
+ /// Gets or sets the reset requirement for the second inventory expansion quest.
+ ///
+ [Display(Name = "Resets for Inventory Expansion #2")]
+ public int ResetsRequiredForInventory2 { get; set; }
+}
+
diff --git a/src/GameLogic/IGameServerContext.cs b/src/GameLogic/IGameServerContext.cs
index 1d8694172..af987315b 100644
--- a/src/GameLogic/IGameServerContext.cs
+++ b/src/GameLogic/IGameServerContext.cs
@@ -5,6 +5,7 @@
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Localization;
///
/// The context of a game server.
@@ -46,6 +47,11 @@ public interface IGameServerContext : IGameContext
///
GameServerConfiguration ServerConfiguration { get; }
+ ///
+ /// Gets the localization service for server messages.
+ ///
+ LocalizationService Localization { get; }
+
///
/// Executes an action for each player of the guild.
///
diff --git a/src/GameLogic/InventoryStorage.cs b/src/GameLogic/InventoryStorage.cs
index 391da9ef9..00ddcdb64 100644
--- a/src/GameLogic/InventoryStorage.cs
+++ b/src/GameLogic/InventoryStorage.cs
@@ -6,6 +6,7 @@ namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.DataModel;
using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.Views.Character;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.PlugIns;
using static MUnique.OpenMU.DataModel.InventoryConstants;
@@ -118,6 +119,34 @@ public override async ValueTask AddItemAsync(byte slot, Item item)
return success;
}
+ ///
+ public override async ValueTask AddItemAsync(Item item)
+ {
+ Item? convertedItem = null;
+ if (item is TemporaryItem temporaryItem)
+ {
+ convertedItem = temporaryItem.MakePersistent(this._player.PersistenceContext);
+ }
+
+ var success = await base.AddItemAsync(convertedItem ?? item).ConfigureAwait(false);
+ if (!success && convertedItem != null)
+ {
+ this._player.PersistenceContext.Detach(convertedItem);
+ }
+
+ if (success)
+ {
+ var finalItem = convertedItem ?? item;
+ var isEquippedItem = this.IsWearingSlot(finalItem.ItemSlot);
+ if (isEquippedItem && this.EquippedItemsChanged is { } eventHandler)
+ {
+ await eventHandler(new ItemEventArgs(finalItem, true)).ConfigureAwait(false);
+ }
+ }
+
+ return success;
+ }
+
///
public override async ValueTask RemoveItemAsync(Item item)
{
@@ -172,6 +201,10 @@ await this._player.ForEachWorldObserverAsync(
this._player.Attributes[Stats.AmmunitionAmount] = (float)ammoItem.Durability;
}
}
+
+ await this._player.InvokeViewPlugInAsync(
+ p => p.UpdateCharacterStatsAsync())
+ .ConfigureAwait(false);
}
private void InitializePowerUps()
@@ -220,4 +253,4 @@ private void UpdateSetPowerUps()
var factory = this._gameContext.ItemPowerUpFactory;
this._player.Attributes.ItemSetPowerUps = factory.GetSetPowerUps(this.EquippedItems, this._player.Attributes, this._player.GameContext.Configuration).ToList();
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/ItemConstants.cs b/src/GameLogic/ItemConstants.cs
index 7fe87facb..6c424d110 100644
--- a/src/GameLogic/ItemConstants.cs
+++ b/src/GameLogic/ItemConstants.cs
@@ -138,4 +138,16 @@ public class ItemConstants
/// Gets all scrolls.
///
public static ItemIdentifier AllScrolls => new(null, 15);
-}
\ No newline at end of file
+
+ ///
+ /// Gets the identifier for the Inventory Expansion item (defaults to group 14, number 92).
+ /// Adjust to match your client if needed.
+ ///
+ public static ItemIdentifier InventoryExpansion => new(92, 14);
+
+ ///
+ /// Gets the identifier for the Vault Extension item (defaults to group 14, number 91).
+ /// Adjust to match your client if needed.
+ ///
+ public static ItemIdentifier VaultExtension => new(91, 14);
+}
diff --git a/src/GameLogic/MUnique.OpenMU.GameLogic.csproj b/src/GameLogic/MUnique.OpenMU.GameLogic.csproj
index 7361433dc..8f7a7006b 100644
--- a/src/GameLogic/MUnique.OpenMU.GameLogic.csproj
+++ b/src/GameLogic/MUnique.OpenMU.GameLogic.csproj
@@ -33,6 +33,7 @@
+
diff --git a/src/GameLogic/MiniGames/MiniGameContext.cs b/src/GameLogic/MiniGames/MiniGameContext.cs
index 19931c625..30264918f 100644
--- a/src/GameLogic/MiniGames/MiniGameContext.cs
+++ b/src/GameLogic/MiniGames/MiniGameContext.cs
@@ -535,7 +535,8 @@ protected async ValueTask SaveRankingAsync(IEnumerable<(int Rank, Character Char
case MiniGameRewardType.Money:
if (!player.TryAddMoney(reward.RewardAmount))
{
- await player.ShowMessageAsync("Couldn't add reward money, inventory is full.").ConfigureAwait(false);
+ var rewardFull = player.GetLocalizedMessage("MiniGame_Message_RewardInventoryFull", "Couldn't add reward money, inventory is full.");
+ await player.ShowMessageAsync(rewardFull).ConfigureAwait(false);
}
return (0, reward.RewardAmount);
@@ -679,7 +680,14 @@ private async ValueTask RunGameAsync(CancellationToken cancellationToken)
{
if (this.Definition.MapCreationPolicy != MiniGameMapCreationPolicy.Shared)
{
- await this.ShowMessageAsync($"{this.Definition.Name} starts in {(int)enterDuration.TotalMinutes} minutes.").ConfigureAwait(false);
+ var minutesRemaining = (int)enterDuration.TotalMinutes;
+ await this.ShowLocalizedMessageAsync(
+ "MiniGame_Message_StartCountdown",
+ "{0} will start in {1} minutes.",
+ MessageType.GoldenCenter,
+ this.Definition.Name,
+ minutesRemaining)
+ .ConfigureAwait(false);
}
await Task.Delay(messagePeriod, cancellationToken).ConfigureAwait(false);
@@ -693,7 +701,12 @@ private async ValueTask RunGameAsync(CancellationToken cancellationToken)
await this.CloseEntranceAsync().ConfigureAwait(false);
if (this.PlayerCount < this.MinimumPlayerCount)
{
- await this.ShowMessageAsync($"Can't start with less than {this.MinimumPlayerCount} players.").ConfigureAwait(false);
+ await this.ShowLocalizedMessageAsync(
+ "MiniGame_Message_MinPlayers",
+ "Cannot start with fewer than {0} players.",
+ MessageType.GoldenCenter,
+ this.MinimumPlayerCount)
+ .ConfigureAwait(false);
if (this.Definition.EntranceFee > 0)
{
await this.ForEachPlayerAsync(async player => player.TryAddMoney(this.Definition.EntranceFee)).ConfigureAwait(false);
@@ -778,6 +791,16 @@ private async ValueTask ShowMessageAsync(string message, MessageType messageType
await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, messageType)).AsTask()).ConfigureAwait(false);
}
+ private async ValueTask ShowLocalizedMessageAsync(string key, string fallback, MessageType messageType = MessageType.GoldenCenter, params object?[] arguments)
+ {
+ await this.ForEachPlayerAsync(player =>
+ player.InvokeViewPlugInAsync(p =>
+ {
+ var message = player.GetLocalizedMessage(key, fallback, arguments);
+ return p.ShowMessageAsync(message, messageType);
+ }).AsTask()).ConfigureAwait(false);
+ }
+
private async ValueTask StopAsync()
{
using (await this._enterLock.WriterLockAsync().ConfigureAwait(false))
@@ -1020,7 +1043,11 @@ private async ValueTask AreEquippedItemsAllowedAsync(Player player)
{
if (!this.IsItemAllowedToEquip(item))
{
- await player.ShowMessageAsync($"Can't enter event with equipped item '{item.Definition?.Name ?? item.ToString()}'.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage(
+ "MiniGame_Message_ItemNotAllowed",
+ "You can't enter the event with the equipped item '{0}'.",
+ item.Definition?.Name ?? item.ToString());
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
result = false;
}
}
@@ -1080,4 +1107,4 @@ public bool RegisterKill()
return Interlocked.Increment(ref this._actualKills) == this.RequiredKills;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/MonsterDefinitionAttributeCache.cs b/src/GameLogic/MonsterDefinitionAttributeCache.cs
new file mode 100644
index 000000000..0a8fe56b3
--- /dev/null
+++ b/src/GameLogic/MonsterDefinitionAttributeCache.cs
@@ -0,0 +1,109 @@
+namespace MUnique.OpenMU.GameLogic;
+
+using System.Collections.Concurrent;
+using MUnique.OpenMU.AttributeSystem;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.Persistence;
+
+///
+/// Caches monster base attributes by monster number, loaded once from persistence (similar to AdminPanel data source).
+/// Stores stat-id/value pairs to allow safe cloning into any target type.
+///
+internal static class MonsterDefinitionAttributeCache
+{
+ private static readonly ConcurrentDictionary> Cache = new();
+ private static volatile bool _loaded;
+
+ private static void AddAttributeCorrectly(IGameContext context, ICollection? target, Guid statId, float value)
+ {
+ if (target is null)
+ {
+ return;
+ }
+
+ var def = context.Configuration.Attributes.FirstOrDefault(a => a.Id == statId);
+ if (def is null)
+ {
+ return;
+ }
+
+ var collectionType = target.GetType();
+ if (collectionType.IsGenericType && collectionType.GetGenericTypeDefinition() == typeof(MUnique.OpenMU.Persistence.CollectionAdapter<,>))
+ {
+ var efItemType = collectionType.GetGenericArguments()[1];
+ if (Activator.CreateInstance(efItemType) is MonsterAttribute efAttr)
+ {
+ efAttr.AttributeDefinition = def;
+ efAttr.Value = value;
+ target.Add(efAttr);
+ return;
+ }
+ }
+
+ target.Add(new MonsterAttribute { AttributeDefinition = def, Value = value });
+ }
+
+ public static void EnsureLoaded(IGameContext gameContext)
+ {
+ if (_loaded)
+ {
+ return;
+ }
+
+ lock (Cache)
+ {
+ if (_loaded)
+ {
+ return;
+ }
+
+ try
+ {
+ // Load using the AdminPanel-like data source (includes children according to GameConfigurationHelper)
+ var ds = new GameConfigurationDataSource(gameContext.LoggerFactory.CreateLogger(), gameContext.PersistenceContextProvider);
+ var ownerObj = ds.GetOwnerAsync(default).AsTask().GetAwaiter().GetResult();
+ _ = ds.GetAll(); // touch to ensure attributes get materialized
+ if (ownerObj is GameConfiguration owner)
+ {
+ foreach (var def in owner.Monsters)
+ {
+ if (def?.Attributes is { Count: > 0 })
+ {
+ var pairs = def.Attributes
+ .Where(a => a.AttributeDefinition is { })
+ .Select(a => (a.AttributeDefinition!.Id, a.Value))
+ .ToList();
+ if (pairs.Count > 0)
+ {
+ Cache[def.Number] = pairs;
+ }
+ }
+ }
+ }
+ }
+ catch
+ {
+ // ignore; cache may remain empty and runtime code can handle it.
+ }
+ finally
+ {
+ _loaded = true;
+ }
+ }
+ }
+
+ public static bool TryFillAttributes(IGameContext gameContext, short monsterNumber, MonsterDefinition target)
+ {
+ EnsureLoaded(gameContext);
+ if (Cache.TryGetValue(monsterNumber, out var list) && list.Count > 0)
+ {
+ foreach (var (statId, value) in list)
+ {
+ AddAttributeCorrectly(gameContext, target.Attributes, statId, value);
+ }
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/GameLogic/MuHelper/MuHelper.cs b/src/GameLogic/MuHelper/MuHelper.cs
index 7758943d7..6e1c0d265 100644
--- a/src/GameLogic/MuHelper/MuHelper.cs
+++ b/src/GameLogic/MuHelper/MuHelper.cs
@@ -53,19 +53,22 @@ public async ValueTask TryStartAsync()
{
if (this._runTask is not null)
{
- await this._player.ShowMessageAsync("MU Helper is already running.").ConfigureAwait(false);
+ var message = this._player.GetLocalizedMessage("MuHelper_Message_AlreadyRunning", "MU Helper is already running.");
+ await this._player.ShowMessageAsync(message).ConfigureAwait(false);
return false;
}
if (this._player.Level < this._configuration.MinLevel)
{
- await this._player.ShowMessageAsync($"MU Helper can be used after level {this._configuration.MinLevel}.").ConfigureAwait(false);
+ var message = this._player.GetLocalizedMessage("MuHelper_Message_MinLevel", "MU Helper can be used from level {0}.", this._configuration.MinLevel);
+ await this._player.ShowMessageAsync(message).ConfigureAwait(false);
return false;
}
if (this._player.Level > this._configuration.MaxLevel)
{
- await this._player.ShowMessageAsync($"MU Helper cannot be used after level {this._configuration.MaxLevel}.").ConfigureAwait(false);
+ var message = this._player.GetLocalizedMessage("MuHelper_Message_MaxLevel", "MU Helper cannot be used after level {0}.", this._configuration.MaxLevel);
+ await this._player.ShowMessageAsync(message).ConfigureAwait(false);
return false;
}
@@ -74,7 +77,8 @@ public async ValueTask TryStartAsync()
if (!this._player.TryRemoveMoney(requiredMoney))
{
- await this._player.ShowMessageAsync($"MU Helper requires {this._player.MuHelper.CalculateRequiredMoney()} zen.").ConfigureAwait(false);
+ var message = this._player.GetLocalizedMessage("MuHelper_Message_NotEnoughZen", "MU Helper requires {0} zen.", requiredMoney);
+ await this._player.ShowMessageAsync(message).ConfigureAwait(false);
return false;
}
@@ -176,4 +180,4 @@ private async ValueTask CollectAsync()
await this.StopAsync().ConfigureAwait(false);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/NPC/AttackableNpcBase.cs b/src/GameLogic/NPC/AttackableNpcBase.cs
index 722d7a372..d35154d3e 100644
--- a/src/GameLogic/NPC/AttackableNpcBase.cs
+++ b/src/GameLogic/NPC/AttackableNpcBase.cs
@@ -252,6 +252,12 @@ protected virtual async ValueTask OnDeathAsync(IAttacker attacker)
await this.ForEachWorldObserverAsync(p => p.ObjectGotKilledAsync(this, attacker), true).ConfigureAwait(false);
+ // Do not award experience or drop items for summoned monsters.
+ if (this is Monster { SummonedBy: { } })
+ {
+ return;
+ }
+
var player = this.GetHitNotificationTarget(attacker);
if (player is { })
{
@@ -393,4 +399,4 @@ private async ValueTask DropItemDelayedAsync(Player player, int gainedExp)
player.Logger.LogDebug(ex, "Dropping an item failed after killing '{this}': {ex}", this, ex);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/NPC/SummonedMonsterIntelligence.cs b/src/GameLogic/NPC/SummonedMonsterIntelligence.cs
index 4456897a3..d13ab477e 100644
--- a/src/GameLogic/NPC/SummonedMonsterIntelligence.cs
+++ b/src/GameLogic/NPC/SummonedMonsterIntelligence.cs
@@ -17,6 +17,8 @@ public sealed class SummonedMonsterIntelligence : BasicMonsterIntelligence
public SummonedMonsterIntelligence(Player owner)
{
this.Owner = owner;
+ // Summons should be allowed to walk within safezones to follow their owner.
+ this.CanWalkOnSafezone = true;
}
///
@@ -27,6 +29,12 @@ public SummonedMonsterIntelligence(Player owner)
///
public override void RegisterHit(IAttacker attacker)
{
+ // Do not aggro against the owner, even if the owner hits the summon intentionally.
+ if (attacker is Player p && p == this.Owner)
+ {
+ return;
+ }
+
if (this.CurrentTarget is null
|| attacker.IsInRange(this.Npc.Position, this.Npc.Definition.AttackRange))
{
@@ -71,4 +79,4 @@ protected override async ValueTask CanAttackAsync()
return true;
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs
index 2ce747927..6a173f719 100644
--- a/src/GameLogic/Player.cs
+++ b/src/GameLogic/Player.cs
@@ -5,6 +5,7 @@
namespace MUnique.OpenMU.GameLogic;
using System;
+using System.Globalization;
using System.Threading;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Attributes;
@@ -67,6 +68,9 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
private Lazy? _comboStateLazy;
+ // Stores the definition of a summon which should be recreated after a map change/warp.
+ private MonsterDefinition? _pendingSummonDefinition;
+
///
/// Initializes a new instance of the class.
/// di
@@ -826,7 +830,19 @@ public bool CompliesRequirements(Item item)
}
}
- return item.Definition.QualifiedCharacters.Contains(this.SelectedCharacter!.CharacterClass!);
+ var characterClass = this.SelectedCharacter!.CharacterClass!;
+ while (characterClass is not null)
+ {
+ if (item.Definition.QualifiedCharacters.Contains(characterClass))
+ {
+ return true;
+ }
+
+ characterClass = this.GameContext.Configuration.CharacterClasses
+ .FirstOrDefault(candidate => candidate.NextGenerationClass == characterClass);
+ }
+
+ return false;
}
///
@@ -1021,6 +1037,15 @@ public async ValueTask RespawnAtAsync(ExitGate gate)
await this.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.EnteredWorld).ConfigureAwait(false);
this.IsAlive = true;
await this.CurrentMap!.AddAsync(this).ConfigureAwait(false);
+ if (!this.CurrentMap.Terrain.SafezoneMap[this.SelectedCharacter.PositionX, this.SelectedCharacter.PositionY]
+ && this.Summon?.Item1 is { IsAlive: true } summon)
+ {
+ var definition = summon.Definition;
+ await summon.DisposeAsync().ConfigureAwait(false);
+ this.Summon = null;
+ await this.CreateSummonedMonsterAsync(definition).ConfigureAwait(false);
+ }
+
}
else
{
@@ -1064,10 +1089,12 @@ public async ValueTask ClientReadyAfterMapChangeAsync()
await this.WarpToSafezoneAsync().ConfigureAwait(false);
}
- if (this.Summon?.Item1 is { IsAlive: true } summon)
+ // Recreate summon on the new map if we had one before warping (even in safezone).
+ if (this._pendingSummonDefinition is { } pending)
{
- await this.CurrentMap.AddAsync(summon).ConfigureAwait(false);
- summon.OnSpawn();
+ var toSpawn = pending;
+ this._pendingSummonDefinition = null;
+ await this.CreateSummonedMonsterAsync(toSpawn).ConfigureAwait(false);
}
}
@@ -1603,17 +1630,53 @@ public async ValueTask CreateSummonedMonsterAsync(MonsterDefinition definition)
throw new InvalidOperationException("Can't add a summon for a player which isn't spawned yet.");
}
+ // Find a valid spawn point close to the player (walkable). Prefer outside safezone, but allow safezone when the owner is there.
+ Point spawnPoint = this.Position;
+ var terrain = gameMap.Terrain;
+ bool found = false;
+ for (var radius = 1; radius <= 5 && !found; radius++)
+ {
+ for (var attempts = 0; attempts < 12 && !found; attempts++)
+ {
+ var p = terrain.GetRandomCoordinate(this.Position, (byte)radius);
+ if (terrain.WalkMap[p.X, p.Y] && (!terrain.SafezoneMap[p.X, p.Y] || terrain.SafezoneMap[this.Position.X, this.Position.Y]))
+ {
+ spawnPoint = p;
+ found = true;
+ }
+ }
+ }
+
+ // If still not found, try owner's current position if walkable.
+ if (!found && terrain.WalkMap[this.Position.X, this.Position.Y])
+ {
+ spawnPoint = this.Position;
+ found = true;
+ }
+
var area = new MonsterSpawnArea
{
GameMap = gameMap.Definition,
MonsterDefinition = definition,
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
Quantity = 1,
- X1 = (byte)Math.Max(this.Position.X - 3, byte.MinValue),
- X2 = (byte)Math.Min(this.Position.X + 3, byte.MaxValue),
- Y1 = (byte)Math.Max(this.Position.Y - 3, byte.MinValue),
- Y2 = (byte)Math.Min(this.Position.Y + 3, byte.MaxValue),
};
+
+ if (found)
+ {
+ area.X1 = area.X2 = spawnPoint.X;
+ area.Y1 = area.Y2 = spawnPoint.Y;
+ this.Logger.LogInformation($"[SUMMON] Spawning at {spawnPoint.X},{spawnPoint.Y} on map {gameMap.Definition.Number}");
+ }
+ else
+ {
+ // Fallback: small area around player; may fail in safezone but it's the best effort.
+ area.X1 = (byte)Math.Max(this.Position.X - 3, byte.MinValue);
+ area.X2 = (byte)Math.Min(this.Position.X + 3, byte.MaxValue);
+ area.Y1 = (byte)Math.Max(this.Position.Y - 3, byte.MinValue);
+ area.Y2 = (byte)Math.Min(this.Position.Y + 3, byte.MaxValue);
+ this.Logger.LogInformation($"[SUMMON] Using fallback area around player: X[{area.X1}-{area.X2}] Y[{area.Y1}-{area.Y2}] on map {gameMap.Definition.Number}");
+ }
var intelligence = new SummonedMonsterIntelligence(this);
var monster = new Monster(area, definition, gameMap, NullDropGenerator.Instance, intelligence, this.GameContext.PlugInManager, this.GameContext.PathFinderPool);
area.MaximumHealthOverride = (int)monster.Attributes[Stats.MaximumHealth];
@@ -1832,6 +1895,13 @@ private async ValueTask TryRemoveFromCurrentMapAsync(bool willRespawnOnSam
return true;
}
+ // If we have a summon, remove it cleanly and remember its definition to recreate later after the map change.
+ if (this.Summon?.Item1 is { IsAlive: true } summonBeforeWarp)
+ {
+ this._pendingSummonDefinition = summonBeforeWarp.Definition;
+ await this.RemoveSummonAsync().ConfigureAwait(false);
+ }
+
if (willRespawnOnSameMap)
{
await currentMap.InitRespawnAsync(this).ConfigureAwait(false);
@@ -1845,10 +1915,7 @@ private async ValueTask TryRemoveFromCurrentMapAsync(bool willRespawnOnSam
this.IsTeleporting = false;
await this._walker.StopAsync().ConfigureAwait(false);
await this._observerToWorldViewAdapter.ClearObservingObjectsListAsync().ConfigureAwait(false);
- if (this.Summon?.Item1 is { IsAlive: true } summon)
- {
- await currentMap.RemoveAsync(summon).ConfigureAwait(false);
- }
+ // Summon (if any) was removed earlier and stored for re-creation.
return true;
}
@@ -2639,6 +2706,34 @@ async ValueTask AddExpToPetAsync(Item pet, double experience)
}
}
+ ///
+ /// Gets a localized message using the players current game context.
+ ///
+ /// The localization key.
+ /// The fallback text if no localization is available.
+ /// Optional formatting arguments.
+ /// The localized message.
+ public string GetLocalizedMessage(string key, string fallback, params object?[] arguments)
+ {
+ if (this.GameContext is IGameServerContext gameServerContext)
+ {
+ var localized = gameServerContext.Localization.GetString(key, arguments);
+ if (!string.Equals(localized, key, StringComparison.Ordinal))
+ {
+ return localized;
+ }
+
+ return Format(fallback, gameServerContext.Localization.CurrentCulture, arguments);
+ }
+
+ return Format(fallback, CultureInfo.InvariantCulture, arguments);
+ }
+
+ private static string Format(string text, CultureInfo culture, params object?[] arguments)
+ {
+ return arguments.Length > 0 ? string.Format(culture, text, arguments) : text;
+ }
+
private async ValueTask CloseTradeIfNeededAsync()
{
if (this.PlayerState.CurrentState == GameLogic.PlayerState.TradeButtonPressed
diff --git a/src/GameLogic/PlayerActions/Character/CreateCharacterAction.cs b/src/GameLogic/PlayerActions/Character/CreateCharacterAction.cs
index c31d30838..ab1991f76 100644
--- a/src/GameLogic/PlayerActions/Character/CreateCharacterAction.cs
+++ b/src/GameLogic/PlayerActions/Character/CreateCharacterAction.cs
@@ -104,7 +104,8 @@ public async ValueTask CreateCharacterAsync(Player player, string characterName,
var message = ex.InnerException?.Message ?? ex.Message;
if (message.Contains("IX_Character_Name") || message.Contains("23505"))
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("A character with the same name already exists.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var duplicate = player.GetLocalizedMessage("CharacterCreate_Message_DuplicateName", "A character with the same name already exists.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(duplicate, MessageType.BlueNormal)).ConfigureAwait(false);
}
return null;
@@ -125,4 +126,4 @@ public async ValueTask CreateCharacterAsync(Player player, string characterName,
return null;
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Character/DeleteCharacterAction.cs b/src/GameLogic/PlayerActions/Character/DeleteCharacterAction.cs
index e5b7d0f27..20d4e1ef6 100644
--- a/src/GameLogic/PlayerActions/Character/DeleteCharacterAction.cs
+++ b/src/GameLogic/PlayerActions/Character/DeleteCharacterAction.cs
@@ -59,7 +59,8 @@ private async ValueTask DeleteCharacterRequestAsync(Playe
if (player.GameContext is IGameServerContext gameServerContext && await gameServerContext.GuildServer.GetGuildPositionAsync(character.Id).ConfigureAwait(false) != GuildPosition.Undefined)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Can't delete a guild member. Remove the character from guild first.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("CharacterDelete_Message_GuildMember", "You cannot delete a guild member. Remove the character from the guild first.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
return CharacterDeleteResult.Unsuccessful;
}
@@ -68,4 +69,4 @@ private async ValueTask DeleteCharacterRequestAsync(Playe
await player.PersistenceContext.DeleteAsync(character).ConfigureAwait(false);
return CharacterDeleteResult.Successful;
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Character/IncreaseStatsAction.cs b/src/GameLogic/PlayerActions/Character/IncreaseStatsAction.cs
index 7d051d858..88fde4e7e 100644
--- a/src/GameLogic/PlayerActions/Character/IncreaseStatsAction.cs
+++ b/src/GameLogic/PlayerActions/Character/IncreaseStatsAction.cs
@@ -34,7 +34,8 @@ public async ValueTask IncreaseStatsAsync(Player player, AttributeDefinition tar
if (!selectedCharacter.CanIncreaseStats(amount))
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Not enough level up points available.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Stats_Message_NotEnoughPoints", "You don't have enough level-up points available.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
@@ -48,7 +49,8 @@ public async ValueTask IncreaseStatsAsync(Player player, AttributeDefinition tar
amount = (ushort)(maximumValue - current);
if (amount == 0)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync($"Maximum of {attributeDef.Attribute?.MaximumValue} {attributeDef.Attribute?.Designation} has been reached.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Stats_Message_MaximumReached", "Maximum of {0} {1} reached.", attributeDef.Attribute?.MaximumValue ?? 0, attributeDef.Attribute?.Designation ?? string.Empty);
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
}
@@ -60,7 +62,8 @@ public async ValueTask IncreaseStatsAsync(Player player, AttributeDefinition tar
}
else
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Attribute not available.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Stats_Message_AttributeUnavailable", "Attribute not available.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Chat/BannableChatMessageBaseProcessor.cs b/src/GameLogic/PlayerActions/Chat/BannableChatMessageBaseProcessor.cs
index 2430b859a..426a56f97 100644
--- a/src/GameLogic/PlayerActions/Chat/BannableChatMessageBaseProcessor.cs
+++ b/src/GameLogic/PlayerActions/Chat/BannableChatMessageBaseProcessor.cs
@@ -20,11 +20,11 @@ public async ValueTask ProcessMessageAsync(Player sender, (string Message, strin
{
if (remainingChatBan.TotalMinutes >= 1)
{
- await this.SendMessageToPlayerAsync(sender, $"Chat Ban: {(int)Math.Ceiling(remainingChatBan.TotalMinutes)} minute(s) remaining.", MessageType.BlueNormal).ConfigureAwait(false);
+ await this.SendMessageToPlayerAsync(sender, $"Chat bloqueado: quedan {(int)Math.Ceiling(remainingChatBan.TotalMinutes)} minuto(s).", MessageType.BlueNormal).ConfigureAwait(false);
}
else
{
- await this.SendMessageToPlayerAsync(sender, $"Chat Ban: {(int)Math.Ceiling(remainingChatBan.TotalSeconds)} second(s) remaining.", MessageType.BlueNormal).ConfigureAwait(false);
+ await this.SendMessageToPlayerAsync(sender, $"Chat bloqueado: quedan {(int)Math.Ceiling(remainingChatBan.TotalSeconds)} segundo(s).", MessageType.BlueNormal).ConfigureAwait(false);
}
return;
@@ -57,4 +57,4 @@ private async ValueTask SendMessageToPlayerAsync(Player player, string message,
{
await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, type)).ConfigureAwait(false);
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Craftings/BaseEventTicketCrafting.cs b/src/GameLogic/PlayerActions/Craftings/BaseEventTicketCrafting.cs
index 041293c20..097f84440 100644
--- a/src/GameLogic/PlayerActions/Craftings/BaseEventTicketCrafting.cs
+++ b/src/GameLogic/PlayerActions/Craftings/BaseEventTicketCrafting.cs
@@ -37,9 +37,10 @@ protected BaseEventTicketCrafting(string resultItemName, string requiredEventIte
protected virtual CraftingResult IncorrectMixItemsResult => CraftingResult.IncorrectMixItems;
///
- public override CraftingResult? TryGetRequiredItems(Player player, out IList itemLinks, out byte successRate)
+ public override CraftingResult? TryGetRequiredItems(Player player, out IList itemLinks, out byte successRate, out byte bonusRate)
{
successRate = 0;
+ bonusRate = 0;
itemLinks = new List(3);
var item1 = player.TemporaryStorage!.Items.FirstOrDefault(item => item.Definition?.Name == this._requiredEventItemName1);
diff --git a/src/GameLogic/PlayerActions/Craftings/DinorantCrafting.cs b/src/GameLogic/PlayerActions/Craftings/DinorantCrafting.cs
index 0bf6ba897..1adb23ae1 100644
--- a/src/GameLogic/PlayerActions/Craftings/DinorantCrafting.cs
+++ b/src/GameLogic/PlayerActions/Craftings/DinorantCrafting.cs
@@ -25,9 +25,9 @@ public DinorantCrafting(SimpleCraftingSettings settings)
}
///
- public override CraftingResult? TryGetRequiredItems(Player player, out IList items, out byte successRate)
+ public override CraftingResult? TryGetRequiredItems(Player player, out IList items, out byte successRate, out byte bonusRate)
{
- var craftingResult = base.TryGetRequiredItems(player, out items, out successRate);
+ var craftingResult = base.TryGetRequiredItems(player, out items, out successRate, out bonusRate);
if (craftingResult is null)
{
var uniriaLink = items.Where(i => i.ItemRequirement.PossibleItems.Any(i => i.Name == "Horn of Uniria"));
diff --git a/src/GameLogic/PlayerActions/Craftings/FenrirUpgradeCrafting.cs b/src/GameLogic/PlayerActions/Craftings/FenrirUpgradeCrafting.cs
index 39a9815df..58276d9f9 100644
--- a/src/GameLogic/PlayerActions/Craftings/FenrirUpgradeCrafting.cs
+++ b/src/GameLogic/PlayerActions/Craftings/FenrirUpgradeCrafting.cs
@@ -18,9 +18,10 @@ public class FenrirUpgradeCrafting : BaseItemCraftingHandler
private readonly ItemPriceCalculator _priceCalculator = new();
///
- public override CraftingResult? TryGetRequiredItems(Player player, out IList items, out byte successRateByItems)
+ public override CraftingResult? TryGetRequiredItems(Player player, out IList items, out byte successRateByItems, out byte bonusRate)
{
successRateByItems = 0;
+ bonusRate = 0;
items = new List(4);
var inputItems = player.TemporaryStorage!.Items.ToList();
var itemsLevelAndOption4 = inputItems
diff --git a/src/GameLogic/PlayerActions/Craftings/FenrirUpgradeCraftingGold.cs b/src/GameLogic/PlayerActions/Craftings/FenrirUpgradeCraftingGold.cs
index 24f68c838..0b4fe1b1b 100644
--- a/src/GameLogic/PlayerActions/Craftings/FenrirUpgradeCraftingGold.cs
+++ b/src/GameLogic/PlayerActions/Craftings/FenrirUpgradeCraftingGold.cs
@@ -19,9 +19,10 @@ public class FenrirUpgradeCraftingGold : BaseItemCraftingHandler
private readonly ItemPriceCalculator _priceCalculator = new();
///
- public override CraftingResult? TryGetRequiredItems(Player player, out IList items, out byte successRateByItems)
+ public override CraftingResult? TryGetRequiredItems(Player player, out IList items, out byte successRateByItems, out byte bonusRate)
{
successRateByItems = 0;
+ bonusRate = 0;
items = new List(4);
var inputItems = player.TemporaryStorage!.Items.ToList();
var itemsLevelAndOption4gold = inputItems
diff --git a/src/GameLogic/PlayerActions/Craftings/GuardianOptionCrafting.cs b/src/GameLogic/PlayerActions/Craftings/GuardianOptionCrafting.cs
index ee458793a..eadccaeb4 100644
--- a/src/GameLogic/PlayerActions/Craftings/GuardianOptionCrafting.cs
+++ b/src/GameLogic/PlayerActions/Craftings/GuardianOptionCrafting.cs
@@ -29,9 +29,9 @@ public GuardianOptionCrafting(SimpleCraftingSettings settings)
public static byte ItemReference { get; } = 0x88;
///
- public override CraftingResult? TryGetRequiredItems(Player player, out IList items, out byte successRate)
+ public override CraftingResult? TryGetRequiredItems(Player player, out IList items, out byte successRate, out byte bonusRate)
{
- if (base.TryGetRequiredItems(player, out items, out successRate) is { } error)
+ if (base.TryGetRequiredItems(player, out items, out successRate, out bonusRate) is { } error)
{
return error;
}
diff --git a/src/GameLogic/PlayerActions/Craftings/MountSeedSphereCrafting.cs b/src/GameLogic/PlayerActions/Craftings/MountSeedSphereCrafting.cs
index dcf1e44a4..a8ce92c3d 100644
--- a/src/GameLogic/PlayerActions/Craftings/MountSeedSphereCrafting.cs
+++ b/src/GameLogic/PlayerActions/Craftings/MountSeedSphereCrafting.cs
@@ -34,9 +34,9 @@ public MountSeedSphereCrafting(SimpleCraftingSettings settings)
public static byte SocketItemReference { get; } = 0x88;
///
- public override CraftingResult? TryGetRequiredItems(Player player, out IList items, out byte successRate)
+ public override CraftingResult? TryGetRequiredItems(Player player, out IList items, out byte successRate, out byte bonusRate)
{
- var result = base.TryGetRequiredItems(player, out items, out successRate);
+ var result = base.TryGetRequiredItems(player, out items, out successRate, out bonusRate);
if (result != default)
{
return result;
diff --git a/src/GameLogic/PlayerActions/Duel/DuelActions.cs b/src/GameLogic/PlayerActions/Duel/DuelActions.cs
index 7c29485a3..fd1ab74aa 100644
--- a/src/GameLogic/PlayerActions/Duel/DuelActions.cs
+++ b/src/GameLogic/PlayerActions/Duel/DuelActions.cs
@@ -27,13 +27,15 @@ public async ValueTask HandleDuelRequestAsync(Player player, Player target)
if (player.DuelRoom is not null)
{
- await player.ShowMessageAsync("You are already in a duel.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Duel_Message_AlreadyInDuel", "You are already in a duel.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return;
}
if (target.DuelRoom is not null)
{
- await player.ShowMessageAsync("The other player is already in a duel.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Duel_Message_TargetInDuel", "The other player is already in a duel.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return;
}
@@ -168,7 +170,8 @@ public async ValueTask HandleDuelChannelJoinRequestAsync(Player player, byte req
if (duelRoom.Spectators.Count >= config.MaximumSpectatorsPerDuelRoom)
{
player.Logger.LogWarning($"Player {player.Name} tried to join duel channel with index {requestedDuelIndex}, but it is full.");
- await player.ShowMessageAsync("The duel channel is full.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Duel_Message_ChannelFull", "The duel channel is full.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return;
}
@@ -182,7 +185,8 @@ public async ValueTask HandleDuelChannelJoinRequestAsync(Player player, byte req
if (!await duelRoom.TryAddSpectatorAsync(player).ConfigureAwait(false))
{
- await player.ShowMessageAsync("The duel channel is full.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Duel_Message_ChannelFull", "The duel channel is full.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
}
}
@@ -255,53 +259,61 @@ private static async ValueTask CheckIfDuelCanBeStartedAsync(Player player,
if (player.CurrentMap != target.CurrentMap)
{
- await player.ShowMessageAsync("You can only duel players which are in the same map.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Duel_Message_SameMapRequired", "You can only challenge players on the same map.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return false;
}
if (player.CurrentMiniGame is not null)
{
- await player.ShowMessageAsync("You cannot start a duel during a mini game.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Duel_Message_MinigameActive", "You cannot start a duel during a mini-game.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return false;
}
if (selectedCharacter.State >= HeroState.PlayerKiller2ndStage)
{
- await player.ShowMessageAsync("You cannot start a duel while you are a player killer.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Duel_Message_PlayerIsPk", "You cannot start a duel while you are a murderer (PK).");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return false;
}
if (targetCharacter.State >= HeroState.PlayerKiller2ndStage)
{
- await player.ShowMessageAsync("You cannot start a duel with a player killer.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Duel_Message_TargetIsPk", "You cannot start a duel against a murderer (PK).");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return false;
}
if (player.GuildWarContext?.State is GuildWarState.Requested or GuildWarState.Started
|| target.GuildWarContext?.State is GuildWarState.Requested or GuildWarState.Started)
{
- await player.ShowMessageAsync("You cannot start a duel during guild war.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Duel_Message_GuildWarActive", "You cannot start a duel during a guild war.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return false;
}
if (player.IsAnySelfDefenseActive()
|| target.IsAnySelfDefenseActive())
{
- await player.ShowMessageAsync("You cannot start a duel with active self-defense.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Duel_Message_SelfDefenseActive", "You cannot start a duel while self-defense is active.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return false;
}
if (player.OpenedNpc is not null
|| target.OpenedNpc is not null)
{
- await player.ShowMessageAsync("You cannot start a duel when a NPC dialog is opened.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Duel_Message_NpcDialogOpen", "You cannot start a duel while an NPC dialog is open.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return false;
}
if (player.PlayerState.CurrentState != PlayerState.EnteredWorld
|| target.PlayerState.CurrentState != PlayerState.EnteredWorld)
{
- await player.ShowMessageAsync("You cannot start a duel when one of the players has the wrong state.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Duel_Message_InvalidState", "You cannot start a duel if any player is not in the correct state.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return false;
}
@@ -320,4 +332,4 @@ private static async ValueTask CheckIfDuelCanBeStartedAsync(Player player,
return true;
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/EnterQuestMapAction.cs b/src/GameLogic/PlayerActions/EnterQuestMapAction.cs
index 5e7ad40f5..ee711c594 100644
--- a/src/GameLogic/PlayerActions/EnterQuestMapAction.cs
+++ b/src/GameLogic/PlayerActions/EnterQuestMapAction.cs
@@ -81,7 +81,8 @@ public async ValueTask TryEnterQuestMapAsync(Player player)
if (this._price > 0 && !player.TryRemoveMoney(this._price))
{
player.Logger.LogError($"Not enough money to enter the map.");
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync($"Not enough zen to enter {targetMap.Name}.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("QuestMap_Message_NotEnoughZen", "You don't have enough zen to enter {0}.", targetMap.Name);
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
@@ -97,4 +98,4 @@ public async ValueTask TryEnterQuestMapAsync(Player player)
await partyPlayer.WarpToAsync(targetGate).ConfigureAwait(false);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Guild/GuildKickPlayerAction.cs b/src/GameLogic/PlayerActions/Guild/GuildKickPlayerAction.cs
index c120d6596..71fb46eb8 100644
--- a/src/GameLogic/PlayerActions/Guild/GuildKickPlayerAction.cs
+++ b/src/GameLogic/PlayerActions/Guild/GuildKickPlayerAction.cs
@@ -43,7 +43,8 @@ public async ValueTask KickPlayerAsync(Player player, string nickname, string se
if (player.Account!.SecurityCode != null && player.Account.SecurityCode != securityCode)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Wrong Security Code.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Guild_Message_WrongSecurityCode", "Wrong security code.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
player.Logger.LogDebug("Wrong Security Code: [{0}] <> [{1}], Player: {2}", securityCode, player.Account.SecurityCode, player.SelectedCharacter?.Name);
await player.InvokeViewPlugInAsync(p => p.GuildKickResultAsync(GuildKickSuccess.Failed)).ConfigureAwait(false);
diff --git a/src/GameLogic/PlayerActions/HitAction.cs b/src/GameLogic/PlayerActions/HitAction.cs
index 49795a39c..8aa5dbe18 100644
--- a/src/GameLogic/PlayerActions/HitAction.cs
+++ b/src/GameLogic/PlayerActions/HitAction.cs
@@ -12,6 +12,8 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions;
///
public class HitAction
{
+ private const double MaximumElementalResistance = 255.0;
+
///
/// Hits the specified target by the specified player.
///
@@ -109,4 +111,9 @@ public async ValueTask HitAsync(Player player, IAttackable target, byte attackAn
return (skill, effectApplied);
}
-}
\ No newline at end of file
+
+ private static double NormalizeElementalResistance(double resistance)
+ {
+ return Math.Min(1.0, Math.Max(0.0, resistance / MaximumElementalResistance));
+ }
+}
diff --git a/src/GameLogic/PlayerActions/ItemConsumeActions/InventoryExpansionConsumeHandlerPlugIn.cs b/src/GameLogic/PlayerActions/ItemConsumeActions/InventoryExpansionConsumeHandlerPlugIn.cs
new file mode 100644
index 000000000..09fb5b7f8
--- /dev/null
+++ b/src/GameLogic/PlayerActions/ItemConsumeActions/InventoryExpansionConsumeHandlerPlugIn.cs
@@ -0,0 +1,49 @@
+// Copyright (c) MUnique. Licensed under the MIT license.
+
+namespace MUnique.OpenMU.GameLogic.PlayerActions.ItemConsumeActions;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.DataModel;
+using MUnique.OpenMU.GameLogic.Views;
+using MUnique.OpenMU.GameLogic.Views.Character;
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Consumes an "Inventory Expansion" item and increases the characters inventory extensions by 1, up to the maximum.
+///
+[Guid("8F03D1B8-4A11-4F2E-A9C3-4D6F3C0C7B8E")]
+[PlugIn(nameof(InventoryExpansionConsumeHandlerPlugIn), "Expands the character inventory by one extension when the corresponding item is used.")]
+public class InventoryExpansionConsumeHandlerPlugIn : BaseConsumeHandlerPlugIn
+{
+ ///
+ public override ItemIdentifier Key => ItemConstants.InventoryExpansion;
+
+ ///
+ public override async ValueTask ConsumeItemAsync(Player player, Item item, Item? targetItem, FruitUsage fruitUsage)
+ {
+ if (!this.CheckPreconditions(player, item))
+ {
+ return false;
+ }
+
+ var current = player.SelectedCharacter!.InventoryExtensions;
+ if (current >= InventoryConstants.MaximumNumberOfExtensions)
+ {
+ var message = player.GetLocalizedMessage(
+ "InventoryExpansion_Message_MaxReached",
+ "Your inventory is already fully expanded.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
+ return false;
+ }
+
+ player.SelectedCharacter.InventoryExtensions = Math.Min(current + 1, InventoryConstants.MaximumNumberOfExtensions);
+
+ // Inform client about changed inventory size via character stats packet.
+ await player.InvokeViewPlugInAsync(p => p.UpdateCharacterStatsAsync()).ConfigureAwait(false);
+
+ // Consume the source item (reduce durability / delete).
+ await this.ConsumeSourceItemAsync(player, item).ConfigureAwait(false);
+ return true;
+ }
+}
diff --git a/src/GameLogic/PlayerActions/ItemConsumeActions/ItemConsumeAction.cs b/src/GameLogic/PlayerActions/ItemConsumeActions/ItemConsumeAction.cs
index 3bc45e2c6..8a36cc2b4 100644
--- a/src/GameLogic/PlayerActions/ItemConsumeActions/ItemConsumeAction.cs
+++ b/src/GameLogic/PlayerActions/ItemConsumeActions/ItemConsumeAction.cs
@@ -49,7 +49,8 @@ public async ValueTask HandleConsumeRequestAsync(Player player, byte inventorySl
if (consumeHandler is null)
{
await player.InvokeViewPlugInAsync(p => p.RequestedItemConsumptionFailedAsync()).ConfigureAwait(false);
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Using this item is not implemented.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("ItemConsume_Message_NotImplemented", "Using this item is not implemented.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
diff --git a/src/GameLogic/PlayerActions/ItemConsumeActions/SiegePotionConsumeHandlerPlugIn.cs b/src/GameLogic/PlayerActions/ItemConsumeActions/SiegePotionConsumeHandlerPlugIn.cs
index 1200991c7..f799a12ef 100644
--- a/src/GameLogic/PlayerActions/ItemConsumeActions/SiegePotionConsumeHandlerPlugIn.cs
+++ b/src/GameLogic/PlayerActions/ItemConsumeActions/SiegePotionConsumeHandlerPlugIn.cs
@@ -41,7 +41,8 @@ public override async ValueTask ConsumeItemAsync(Player player, Item item,
}
else
{
- await player.ShowMessageAsync("Effect for item not found.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("ItemConsume_Message_EffectNotFound", "Effect for item not found.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
}
return false;
diff --git a/src/GameLogic/PlayerActions/ItemConsumeActions/VaultExtensionConsumeHandlerPlugIn.cs b/src/GameLogic/PlayerActions/ItemConsumeActions/VaultExtensionConsumeHandlerPlugIn.cs
new file mode 100644
index 000000000..2054b0de9
--- /dev/null
+++ b/src/GameLogic/PlayerActions/ItemConsumeActions/VaultExtensionConsumeHandlerPlugIn.cs
@@ -0,0 +1,48 @@
+// Copyright (c) MUnique. Licensed under the MIT license.
+
+namespace MUnique.OpenMU.GameLogic.PlayerActions.ItemConsumeActions;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.GameLogic.Views;
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Consumes a "Vault Extension" item and enables the extended vault for the account.
+///
+[Guid("6F5B09C7-2D0B-4C8E-8A11-3F6E6951E3E2")]
+[PlugIn(nameof(VaultExtensionConsumeHandlerPlugIn), "Extends the vault when the corresponding item is used.")]
+public class VaultExtensionConsumeHandlerPlugIn : BaseConsumeHandlerPlugIn
+{
+ ///
+ public override ItemIdentifier Key => ItemConstants.VaultExtension;
+
+ ///
+ public override async ValueTask ConsumeItemAsync(Player player, Item item, Item? targetItem, FruitUsage fruitUsage)
+ {
+ if (!this.CheckPreconditions(player, item))
+ {
+ return false;
+ }
+
+ if (player.Account is null)
+ {
+ return false;
+ }
+
+ if (player.Account.IsVaultExtended)
+ {
+ var message = player.GetLocalizedMessage(
+ "VaultExtension_Message_AlreadyExtended",
+ "Your vault is already extended.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
+ return false;
+ }
+
+ player.Account.IsVaultExtended = true;
+
+ // Consume the source item (reduce durability / delete).
+ await this.ConsumeSourceItemAsync(player, item).ConfigureAwait(false);
+ return true;
+ }
+}
diff --git a/src/GameLogic/PlayerActions/Items/BaseItemCraftingHandler.cs b/src/GameLogic/PlayerActions/Items/BaseItemCraftingHandler.cs
index 7cfc6a897..a4c49661a 100644
--- a/src/GameLogic/PlayerActions/Items/BaseItemCraftingHandler.cs
+++ b/src/GameLogic/PlayerActions/Items/BaseItemCraftingHandler.cs
@@ -16,25 +16,25 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions.Items;
public abstract class BaseItemCraftingHandler : IItemCraftingHandler
{
///
- public async ValueTask<(CraftingResult Result, Item? Item)> DoMixAsync(Player player, byte socketSlot)
+ public async ValueTask<(CraftingResult Result, Item? Item, byte SuccessRate, byte BonusRate)> DoMixAsync(Player player, byte socketSlot)
{
using var loggerScope = player.Logger.BeginScope(this.GetType());
if (player.TemporaryStorage is null)
{
- return (CraftingResult.Failed, null);
+ return (CraftingResult.Failed, null, 0, 0);
}
- if (this.TryGetRequiredItems(player, out var items, out var successRate) is { } error)
+ if (this.TryGetRequiredItems(player, out var items, out var successRate, out var bonusRate) is { } error)
{
- return (error, null);
+ return (error, null, successRate, bonusRate);
}
- player.Logger.LogInformation("Crafting success chance: {successRate} %", successRate);
+ player.Logger.LogInformation("Crafting success chance: {successRate} % (+{bonusRate})", successRate, bonusRate);
var price = this.GetPrice(successRate, items);
if (!player.TryRemoveMoney(price))
{
- return (CraftingResult.NotEnoughMoney, null);
+ return (CraftingResult.NotEnoughMoney, null, successRate, bonusRate);
}
await player.InvokeViewPlugInAsync(p => p.UpdateMoneyAsync()).ConfigureAwait(false);
@@ -42,7 +42,7 @@ public abstract class BaseItemCraftingHandler : IItemCraftingHandler
var success = Rand.NextRandomBool(successRate);
if (success)
{
- player.Logger.LogInformation("Crafting succeeded with success chance: {successRate} %", successRate);
+ player.Logger.LogInformation("Crafting succeeded with success chance: {successRate} % (+{bonusRate})", successRate, bonusRate);
if (await this.DoTheMixAsync(items, player, socketSlot, successRate).ConfigureAwait(false) is { } item)
{
player.Logger.LogInformation("Crafted item: {item}", item);
@@ -52,15 +52,14 @@ public abstract class BaseItemCraftingHandler : IItemCraftingHandler
// So the best solution is to just clear it and rely on the restore mechanism for the temporary storage.
player.BackupInventory = null;
- return (CraftingResult.Success, item);
+ return (CraftingResult.Success, item, successRate, bonusRate);
}
player.Logger.LogInformation("Crafting handler failed to mix the items.");
- return (CraftingResult.Failed, null);
+ return (CraftingResult.Failed, null, successRate, bonusRate);
}
- player.Logger.LogInformation("Crafting failed with success chance: {successRate} %", successRate);
-
+ player.Logger.LogInformation("Crafting failed with success chance: {successRate} % (+{bonusRate})", successRate, bonusRate);
// Reset backup inventory to avoid items are restored after failure and sudden disconnect of the client.
player.BackupInventory = null;
foreach (var i in items)
@@ -68,11 +67,11 @@ public abstract class BaseItemCraftingHandler : IItemCraftingHandler
await this.RequiredItemChangeAsync(player, i, false).ConfigureAwait(false);
}
- return (CraftingResult.Failed, null);
+ return (CraftingResult.Failed, null, successRate, bonusRate);
}
///
- public abstract CraftingResult? TryGetRequiredItems(Player player, out IList items, out byte successRateByItems);
+ public abstract CraftingResult? TryGetRequiredItems(Player player, out IList items, out byte successRateByItems, out byte bonusRate);
///
/// Gets the price based on the success rate and the required items.
@@ -199,4 +198,4 @@ private async ValueTask RequiredItemChangeAsync(Player player, CraftingRequiredI
break;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Items/BuyNpcItemAction.cs b/src/GameLogic/PlayerActions/Items/BuyNpcItemAction.cs
index b7b90ad1b..72faa9c49 100644
--- a/src/GameLogic/PlayerActions/Items/BuyNpcItemAction.cs
+++ b/src/GameLogic/PlayerActions/Items/BuyNpcItemAction.cs
@@ -47,7 +47,8 @@ public async ValueTask BuyItemAsync(Player player, byte slot)
var storeItem = npcDefinition.MerchantStore.Items.FirstOrDefault(i => i.ItemSlot == slot);
if (storeItem is null)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Item Unknown", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("NpcShop_Message_UnknownItem", "Unknown item.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
await player.InvokeViewPlugInAsync(p => p.BuyNpcItemFailedAsync()).ConfigureAwait(false);
return;
}
@@ -69,14 +70,16 @@ public async ValueTask BuyItemAsync(Player player, byte slot)
var toSlot = player.Inventory!.CheckInvSpace(storeItem);
if (toSlot is null)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Inventory Full", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Inventory_Message_Full", "Inventory full.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
await player.InvokeViewPlugInAsync(p => p.BuyNpcItemFailedAsync()).ConfigureAwait(false);
return;
}
if (!this.CheckMoney(player, storeItem))
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("You don't have enough Money", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("NpcShop_Message_NotEnoughZen", "You don't have enough zen.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
await player.InvokeViewPlugInAsync(p => p.BuyNpcItemFailedAsync()).ConfigureAwait(false);
return;
}
@@ -102,4 +105,4 @@ private bool CheckMoney(Player player, Item item)
return true;
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Items/IItemCraftingHandler.cs b/src/GameLogic/PlayerActions/Items/IItemCraftingHandler.cs
index 3d7706efe..57c976c2b 100644
--- a/src/GameLogic/PlayerActions/Items/IItemCraftingHandler.cs
+++ b/src/GameLogic/PlayerActions/Items/IItemCraftingHandler.cs
@@ -17,8 +17,8 @@ public interface IItemCraftingHandler
///
/// The mixing player.
/// The socket slot index for the and . It's a 0-based index.
- /// The crafting result and the resulting item; if there are multiple, only the last one is returned.
- ValueTask<(CraftingResult Result, Item? Item)> DoMixAsync(Player player, byte socketSlot);
+ /// The crafting result, success information and the resulting item; if there are multiple, only the last one is returned.
+ ValueTask<(CraftingResult Result, Item? Item, byte SuccessRate, byte BonusRate)> DoMixAsync(Player player, byte socketSlot);
///
/// Tries to get the required items for this crafting.
@@ -28,6 +28,7 @@ public interface IItemCraftingHandler
/// The player.
/// The items.
/// The success rate by items.
+ /// The bonus rate.
/// null, if the required items could be get; Otherwise, the corresponding error is returned.
- CraftingResult? TryGetRequiredItems(Player player, out IList items, out byte successRateByItems);
+ CraftingResult? TryGetRequiredItems(Player player, out IList items, out byte successRateByItems, out byte bonusRate);
}
\ No newline at end of file
diff --git a/src/GameLogic/PlayerActions/Items/ItemCraftAction.cs b/src/GameLogic/PlayerActions/Items/ItemCraftAction.cs
index 6376aa1e2..dfd39007f 100644
--- a/src/GameLogic/PlayerActions/Items/ItemCraftAction.cs
+++ b/src/GameLogic/PlayerActions/Items/ItemCraftAction.cs
@@ -27,7 +27,7 @@ public async ValueTask MixItemsAsync(Player player, byte mixTypeId, byte socketS
var crafting = npcStats?.ItemCraftings.FirstOrDefault(c => c.Number == mixTypeId);
if (crafting is null)
{
- await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(CraftingResult.IncorrectMixItems, null)).ConfigureAwait(false);
+ await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(CraftingResult.IncorrectMixItems, 0, 0, null)).ConfigureAwait(false);
return;
}
@@ -37,22 +37,22 @@ public async ValueTask MixItemsAsync(Player player, byte mixTypeId, byte socketS
this._craftingHandlerCache.Add(crafting, craftingHandler);
}
- (CraftingResult, Item?) result;
+ (CraftingResult Result, Item? Item, byte SuccessRate, byte BonusRate) result;
try
{
result = await craftingHandler.DoMixAsync(player, socketSlot).ConfigureAwait(false);
}
catch
{
- result = (CraftingResult.LackingMixItems, null);
+ result = (CraftingResult.LackingMixItems, null, 0, 0);
}
var itemList = player.TemporaryStorage?.Items.ToList() ?? new List();
- await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(result.Item1, itemList.Count > 1 ? null : result.Item2)).ConfigureAwait(false);
+ await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(result.Result, result.SuccessRate, result.BonusRate, itemList.Count > 1 ? null : result.Item)).ConfigureAwait(false);
await player.InvokeViewPlugInAsync(
p => p.ShowMerchantStoreItemListAsync(
itemList,
- npcStats!.NpcWindow == NpcWindow.PetTrainer && result.Item1 != CraftingResult.Success ? StoreKind.ResurrectionFailed : StoreKind.ChaosMachine))
+ npcStats!.NpcWindow == NpcWindow.PetTrainer && result.Result != CraftingResult.Success ? StoreKind.ResurrectionFailed : StoreKind.ChaosMachine))
.ConfigureAwait(false);
}
@@ -75,7 +75,7 @@ await player.InvokeViewPlugInAsync(
this._craftingHandlerCache.Add(itemCrafting, craftingHandler);
}
- if (craftingHandler.TryGetRequiredItems(player, out _, out _) is null)
+ if (craftingHandler.TryGetRequiredItems(player, out _, out _, out _) is null)
{
return itemCrafting;
}
diff --git a/src/GameLogic/PlayerActions/Items/ItemRepairAction.cs b/src/GameLogic/PlayerActions/Items/ItemRepairAction.cs
index b18f55fa4..4ab7ed4b5 100644
--- a/src/GameLogic/PlayerActions/Items/ItemRepairAction.cs
+++ b/src/GameLogic/PlayerActions/Items/ItemRepairAction.cs
@@ -30,7 +30,8 @@ public async ValueTask RepairItemAsync(Player player, byte slot)
var item = player.Inventory?.GetItem(slot);
if (item is null)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("No Item there to repair.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("ItemRepair_Message_NoItem", "There are no items to repair in that slot.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
player.Logger.LogWarning("RepairItem: Player {0}, Itemslot {1} not filled", player.SelectedCharacter?.Name, slot);
return;
}
@@ -47,7 +48,8 @@ public async ValueTask RepairItemAsync(Player player, byte slot)
}
else
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("You don't have enough money to repair.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("ItemRepair_Message_NotEnoughZen", "You don't have enough zen to repair.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
}
}
@@ -94,7 +96,8 @@ public async ValueTask RepairAllItemsAsync(Player player)
}
else
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("You don't have enough money to repair.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("ItemRepair_Message_NotEnoughZen", "You don't have enough zen to repair.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
break;
}
}
@@ -106,4 +109,4 @@ private static bool IsMoneySufficient(Player player, Item item)
var price = priceCalculator.CalculateRepairPrice(item, player.OpenedNpc != null);
return player.TryRemoveMoney((int)price);
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Items/ItemStackAction.cs b/src/GameLogic/PlayerActions/Items/ItemStackAction.cs
index 580d7a54e..da81b5445 100644
--- a/src/GameLogic/PlayerActions/Items/ItemStackAction.cs
+++ b/src/GameLogic/PlayerActions/Items/ItemStackAction.cs
@@ -56,7 +56,8 @@ public async ValueTask StackItemsAsync(Player player, byte stackId, byte stackSi
}
else
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("You are lacking of Jewels.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("ItemStack_Message_NotEnoughJewels", "You don't have enough jewels.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
}
}
@@ -83,13 +84,15 @@ public async ValueTask UnstackItemsAsync(Player player, byte stackId, byte slot)
var stacked = player.Inventory?.GetItem(slot);
if (stacked is null)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Stacked Jewel not found.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("ItemStack_Message_StackNotFound", "The stacked jewel was not found.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
if (stacked.Definition != mix.MixedJewel)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Selected Item is not a stacked Jewel.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("ItemStack_Message_WrongItem", "The selected item is not a stacked jewel.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
@@ -98,7 +101,8 @@ public async ValueTask UnstackItemsAsync(Player player, byte stackId, byte slot)
var freeSlots = player.Inventory!.FreeSlots.Take(pieces).ToList();
if (freeSlots.Count < pieces)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Inventory got not enough Space.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Inventory_Message_NotEnoughSpace", "There is not enough space in the inventory.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
@@ -136,4 +140,4 @@ private bool IsCorrectNpcOpened(Player player)
return mix;
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Items/LostMapDroppedPlugIn.cs b/src/GameLogic/PlayerActions/Items/LostMapDroppedPlugIn.cs
index 1dc6b1ae9..def06bb4f 100644
--- a/src/GameLogic/PlayerActions/Items/LostMapDroppedPlugIn.cs
+++ b/src/GameLogic/PlayerActions/Items/LostMapDroppedPlugIn.cs
@@ -41,20 +41,23 @@ public async ValueTask HandleItemDropAsync(Player player, Item item, Point targe
if (item.Level is < 1 or > 7)
{
- await player.ShowMessageAsync("The lost map is not valid.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("LostMap_Message_InvalidMap", "The lost map is not valid.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return;
}
if (player.CurrentMiniGame is not null)
{
- await player.ShowMessageAsync("Cannot create kalima gate on event map.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("LostMap_Message_EventMap", "Cannot create Kalima gate on event map.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return;
}
var gatePosition = target;
if (player.IsAtSafezone() || player.CurrentMap?.Terrain.SafezoneMap[gatePosition.X, gatePosition.Y] is true)
{
- await player.ShowMessageAsync("Cannot create kalima gate in safe zone.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("LostMap_Message_SafeZone", "Cannot create Kalima gate in safe zone.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return;
}
@@ -62,7 +65,8 @@ public async ValueTask HandleItemDropAsync(Player player, Item item, Point targe
var gateNpcDef = player.GameContext.Configuration.Monsters.FirstOrDefault(def => def.Number == gateNpcNumber);
if (gateNpcDef is null)
{
- await player.ShowMessageAsync("The gate npc is not defined.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("LostMap_Message_NpcMissing", "The gate NPC is not defined.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return;
}
@@ -81,7 +85,8 @@ public async ValueTask HandleItemDropAsync(Player player, Item item, Point targe
var targetGate = player.GameContext.Configuration.Maps.FirstOrDefault(g => g.Number == KalimaMapNumbers[item.Level - 1])?.ExitGates.FirstOrDefault();
if (targetGate is null)
{
- await player.ShowMessageAsync("The kalima entrance wasn't found.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("LostMap_Message_EntranceNotFound", "The Kalima entrance wasn't found.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return;
}
diff --git a/src/GameLogic/PlayerActions/Items/MoveItemAction.cs b/src/GameLogic/PlayerActions/Items/MoveItemAction.cs
index 9dc0a14a3..1c02f847a 100644
--- a/src/GameLogic/PlayerActions/Items/MoveItemAction.cs
+++ b/src/GameLogic/PlayerActions/Items/MoveItemAction.cs
@@ -219,7 +219,8 @@ private async ValueTask CanMoveAsync(Player player, Item item, byte to
&& toStorage.Storage == player.Inventory
&& player.IsVaultLocked)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("The vault is locked.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var vaultLocked = player.GetLocalizedMessage("Inventory_Message_VaultLocked", "The vault is locked.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(vaultLocked, MessageType.BlueNormal)).ConfigureAwait(false);
return Movement.None;
}
@@ -235,7 +236,8 @@ private async ValueTask CanMoveAsync(Player player, Item item, byte to
if (player.CurrentMiniGame is { } miniGame
&& !miniGame.IsItemAllowedToEquip(item))
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("You can't equip this item during the event.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var blockedByEvent = player.GetLocalizedMessage("Inventory_Message_ItemBlockedByEvent", "You can't equip this item during the event.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(blockedByEvent, MessageType.BlueNormal)).ConfigureAwait(false);
return Movement.None;
}
@@ -263,13 +265,15 @@ static bool IsOneHandedOrShield(ItemDefinition definition) =>
return Movement.Normal;
}
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("You can't wear this item.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var cannotWear = player.GetLocalizedMessage("Inventory_Message_CantWear", "You can't wear this item.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(cannotWear, MessageType.BlueNormal)).ConfigureAwait(false);
return Movement.None;
}
if (item.Definition!.IsBoundToCharacter && toStorage != fromStorage)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("This item is bound to the inventory of this character.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var bound = player.GetLocalizedMessage("Inventory_Message_BoundItem", "This item is bound to the inventory of this character.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(bound, MessageType.BlueNormal)).ConfigureAwait(false);
return Movement.None;
}
@@ -382,4 +386,4 @@ private void SetUsedSlots(StorageInfo toStorage, Item blockingItem, bool[,] used
}
private record StorageInfo(IStorage Storage, byte Rows, byte StartIndex, byte EndIndex);
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Items/PickupItemAction.cs b/src/GameLogic/PlayerActions/Items/PickupItemAction.cs
index b99fa26dd..3be7c5177 100644
--- a/src/GameLogic/PlayerActions/Items/PickupItemAction.cs
+++ b/src/GameLogic/PlayerActions/Items/PickupItemAction.cs
@@ -105,7 +105,8 @@ private static bool IsLimitReached(Player player, ItemDefinition? itemDefinition
? $"{droppedItem.Item.Definition?.Name} +{droppedItem.Item.Level}"
: droppedItem.Item.Definition?.Name;
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync($"Limit reached for '{itemName}'.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Inventory_Message_ItemLimitReached", "Limit reached for '{0}'.", itemName ?? string.Empty);
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
return (false, null);
}
diff --git a/src/GameLogic/PlayerActions/Items/SimpleItemCraftingHandler.cs b/src/GameLogic/PlayerActions/Items/SimpleItemCraftingHandler.cs
index 0ff7a91a0..95661bff5 100644
--- a/src/GameLogic/PlayerActions/Items/SimpleItemCraftingHandler.cs
+++ b/src/GameLogic/PlayerActions/Items/SimpleItemCraftingHandler.cs
@@ -27,9 +27,10 @@ public SimpleItemCraftingHandler(SimpleCraftingSettings settings)
}
///
- public override CraftingResult? TryGetRequiredItems(Player player, out IList items, out byte successRate)
+ public override CraftingResult? TryGetRequiredItems(Player player, out IList items, out byte successRate, out byte bonusRate)
{
successRate = 0;
+ bonusRate = 0;
int rate = this._settings.SuccessPercent;
long totalCraftingPrice = 0;
items = new List(this._settings.RequiredItems.Count);
@@ -117,6 +118,7 @@ public SimpleItemCraftingHandler(SimpleCraftingSettings settings)
}
successRate = (byte)Math.Min(100, rate);
+ bonusRate = (byte)Math.Max(0, successRate - this._settings.SuccessPercent);
return default;
}
diff --git a/src/GameLogic/PlayerActions/Messenger/LetterSendAction.cs b/src/GameLogic/PlayerActions/Messenger/LetterSendAction.cs
index d89ed147e..cdfb74b4f 100644
--- a/src/GameLogic/PlayerActions/Messenger/LetterSendAction.cs
+++ b/src/GameLogic/PlayerActions/Messenger/LetterSendAction.cs
@@ -30,7 +30,8 @@ public async ValueTask SendLetterAsync(Player player, string receiver, string me
var sendPrice = player.GameContext.Configuration.LetterSendPrice;
if (player.Money < sendPrice)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Not enough Zen to send a letter.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var localizedMessage = player.GetLocalizedMessage("LetterSend_Message_NotEnoughZen", "You don't have enough zen to send a letter.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(localizedMessage, MessageType.BlueNormal)).ConfigureAwait(false);
await player.InvokeViewPlugInAsync(p => p.LetterSendResultAsync(LetterSendSuccess.NotEnoughMoney, letterId)).ConfigureAwait(false);
return;
}
@@ -63,7 +64,8 @@ public async ValueTask SendLetterAsync(Player player, string receiver, string me
{
player.Logger.LogError(ex, "Unexpected error when trying to send a letter");
await player.InvokeViewPlugInAsync(p => p.LetterSendResultAsync(LetterSendSuccess.TryAgain, letterId)).ConfigureAwait(false);
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Oops, some error happened during sending the Letter.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var localizedMessage = player.GetLocalizedMessage("LetterSend_Message_Error", "Oops, an error occurred while sending the letter.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(localizedMessage, MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
@@ -92,4 +94,4 @@ private LetterHeader CreateLetter(IContext context, Player player, string receiv
letterBody.Animation = animation;
return letterHeader;
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/MiniGames/MiniGameOpeningStateRequestAction.cs b/src/GameLogic/PlayerActions/MiniGames/MiniGameOpeningStateRequestAction.cs
index 58e5bd6a2..d36f614b8 100644
--- a/src/GameLogic/PlayerActions/MiniGames/MiniGameOpeningStateRequestAction.cs
+++ b/src/GameLogic/PlayerActions/MiniGames/MiniGameOpeningStateRequestAction.cs
@@ -40,12 +40,18 @@ public async ValueTask HandleRequestAsync(Player player, MiniGameType miniGameTy
{
case MiniGameType.BloodCastle:
case MiniGameType.DevilSquare:
- await player.ShowMessageAsync("Event map is created on entrance. No fixed time table.").ConfigureAwait(false);
- break;
+ {
+ var message = player.GetLocalizedMessage("MiniGame_Message_EntranceCreatesMap", "Event map is created on entrance. No fixed timetable.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
+ break;
+ }
case MiniGameType.Doppelganger:
case MiniGameType.IllusionTemple:
- await player.ShowMessageAsync("This event is not implemented yet.").ConfigureAwait(false);
- break;
+ {
+ var message = player.GetLocalizedMessage("MiniGame_Message_NotImplemented", "This event is not implemented yet.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
+ break;
+ }
default:
throw new ArgumentOutOfRangeException($"Unhandled event type {miniGameType}.");
}
diff --git a/src/GameLogic/PlayerActions/MuHelper/ChangeMuHelperStateAction.cs b/src/GameLogic/PlayerActions/MuHelper/ChangeMuHelperStateAction.cs
index d77ac14f2..538c61bed 100644
--- a/src/GameLogic/PlayerActions/MuHelper/ChangeMuHelperStateAction.cs
+++ b/src/GameLogic/PlayerActions/MuHelper/ChangeMuHelperStateAction.cs
@@ -24,7 +24,8 @@ public async ValueTask ChangeHelperStateAsync(Player player, MuHelperStatus stat
if (configuration is null)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("MU Helper is disabled", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("MuHelper_Message_Disabled", "MU Helper is disabled.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
@@ -37,8 +38,9 @@ public async ValueTask ChangeHelperStateAsync(Player player, MuHelperStatus stat
await player.MuHelper.StopAsync().ConfigureAwait(false);
break;
default: // unknown
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync($"MU Helper can't handle status: {status}", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("MuHelper_Message_UnknownState", "MU Helper cannot handle state {0}.", status);
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
break;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Party/PartyRequestAction.cs b/src/GameLogic/PlayerActions/Party/PartyRequestAction.cs
index a409e2409..588ad08c5 100644
--- a/src/GameLogic/PlayerActions/Party/PartyRequestAction.cs
+++ b/src/GameLogic/PlayerActions/Party/PartyRequestAction.cs
@@ -23,26 +23,26 @@ public async ValueTask HandlePartyRequestAsync(Player player, Player toRequest)
var isPartyMember = player.Party != null && !Equals(player.Party.PartyMaster, player);
if (player.CurrentMiniGame?.Definition.AllowParty is false)
{
- await this.SendMessageToPlayerAsync(player, "A party is not possible during this event.", MessageType.BlueNormal).ConfigureAwait(false);
+ await this.SendMessageToPlayerAsync(player, "No es posible formar party durante este evento.", MessageType.BlueNormal).ConfigureAwait(false);
return;
}
if (toRequest.Party != null || toRequest.LastPartyRequester != null)
{
- await this.SendMessageToPlayerAsync(player, $"{toRequest.Name} is already in a party.", MessageType.BlueNormal).ConfigureAwait(false);
+ await this.SendMessageToPlayerAsync(player, $"{toRequest.Name} ya está en un party.", MessageType.BlueNormal).ConfigureAwait(false);
return;
}
if (isPartyMember)
{
- await this.SendMessageToPlayerAsync(player, "You are not the Party Master.", MessageType.BlueNormal).ConfigureAwait(false);
+ await this.SendMessageToPlayerAsync(player, "No eres el líder del party.", MessageType.BlueNormal).ConfigureAwait(false);
return;
}
if (await toRequest.PlayerState.TryAdvanceToAsync(PlayerState.PartyRequest).ConfigureAwait(false))
{
await this.SendPartyRequestAsync(toRequest, player).ConfigureAwait(false);
- await this.SendMessageToPlayerAsync(player, $"Requested {toRequest.Name} for Party.", MessageType.BlueNormal).ConfigureAwait(false);
+ await this.SendMessageToPlayerAsync(player, $"Has enviado una solicitud de party a {toRequest.Name}.", MessageType.BlueNormal).ConfigureAwait(false);
}
}
@@ -66,4 +66,4 @@ private async ValueTask SendMessageToPlayerAsync(IPartyMember partyMember, strin
await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, type)).ConfigureAwait(false);
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Party/PartyResponseAction.cs b/src/GameLogic/PlayerActions/Party/PartyResponseAction.cs
index 9b20c2fe8..85f5daa42 100644
--- a/src/GameLogic/PlayerActions/Party/PartyResponseAction.cs
+++ b/src/GameLogic/PlayerActions/Party/PartyResponseAction.cs
@@ -47,7 +47,8 @@ public async ValueTask HandleResponseAsync(Player player, bool accepted)
if (player.CurrentMiniGame?.Definition.AllowParty is false)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("A party is not possible during this event.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Party_Message_DisabledInEvent", "A party is not possible during this event.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
player.LastPartyRequester = null;
return;
}
diff --git a/src/GameLogic/PlayerActions/PlayerStore/BuyRequestAction.cs b/src/GameLogic/PlayerActions/PlayerStore/BuyRequestAction.cs
index 28a3486ed..4400ecd4d 100644
--- a/src/GameLogic/PlayerActions/PlayerStore/BuyRequestAction.cs
+++ b/src/GameLogic/PlayerActions/PlayerStore/BuyRequestAction.cs
@@ -66,7 +66,8 @@ public async ValueTask BuyItemAsync(Player player, Player requestedPlayer, byte
if (freeslot is null)
{
await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(requestedPlayer, ItemBuyResult.MoneyOverflowOrNotEnoughSpace, null)).ConfigureAwait(false);
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Not enough space in your inventory.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("PlayerStore_Message_NotEnoughSpace", "There is not enough space in your inventory.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
@@ -113,7 +114,8 @@ public async ValueTask BuyItemAsync(Player player, Player requestedPlayer, byte
else
{
await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(requestedPlayer, ItemBuyResult.MoneyOverflowOrNotEnoughSpace, null)).ConfigureAwait(false);
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("The inventory of the seller is full.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("PlayerStore_Message_SellerInventoryFull", "The seller's inventory is full.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
player.TryAddMoney(itemPrice);
}
}
@@ -132,4 +134,4 @@ public async ValueTask BuyItemAsync(Player player, Player requestedPlayer, byte
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/PlayerStore/StoreItemListRequestAction.cs b/src/GameLogic/PlayerActions/PlayerStore/StoreItemListRequestAction.cs
index ab61e2066..5e20a3f24 100644
--- a/src/GameLogic/PlayerActions/PlayerStore/StoreItemListRequestAction.cs
+++ b/src/GameLogic/PlayerActions/PlayerStore/StoreItemListRequestAction.cs
@@ -22,11 +22,12 @@ public async ValueTask RequestStoreItemListAsync(Player player, Player requested
{
if (!(requestedPlayer.ShopStorage?.StoreOpen ?? false))
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Player's Store not open.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("PlayerStore_Message_NotOpen", "The player store is not open.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
player.LastRequestedPlayerStore = new WeakReference(requestedPlayer);
await player.InvokeViewPlugInAsync(p => p.ShowShopItemListAsync(requestedPlayer, false)).ConfigureAwait(false);
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Quests/ElfSoldierBuffRequestAction.cs b/src/GameLogic/PlayerActions/Quests/ElfSoldierBuffRequestAction.cs
index b5675fb5d..ee5fc9302 100644
--- a/src/GameLogic/PlayerActions/Quests/ElfSoldierBuffRequestAction.cs
+++ b/src/GameLogic/PlayerActions/Quests/ElfSoldierBuffRequestAction.cs
@@ -43,7 +43,8 @@ public async ValueTask RequestBuffAsync(Player player)
if (player.Level > 220)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("You're strong enough on your own.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("ElfSoldier_Message_StrongEnough", "You're strong enough on your own.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
diff --git a/src/GameLogic/PlayerActions/Quests/QuestCompletionAction.cs b/src/GameLogic/PlayerActions/Quests/QuestCompletionAction.cs
index cb3e91dde..b44b2874f 100644
--- a/src/GameLogic/PlayerActions/Quests/QuestCompletionAction.cs
+++ b/src/GameLogic/PlayerActions/Quests/QuestCompletionAction.cs
@@ -90,6 +90,7 @@ public async ValueTask CompleteQuestAsync(Player player, short group, short numb
}
await questState.ClearAsync(player.PersistenceContext).ConfigureAwait(false);
+ await player.InvokeViewPlugInAsync(p => p.UpdateCharacterStatsAsync()).ConfigureAwait(false);
await player.InvokeViewPlugInAsync(p => p.QuestCompletedAsync(activeQuest)).ConfigureAwait(false);
}
@@ -194,4 +195,4 @@ await player.ForEachWorldObserverAsync(
break;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Quests/QuestStartAction.cs b/src/GameLogic/PlayerActions/Quests/QuestStartAction.cs
index 3654d8f07..9b42155a1 100644
--- a/src/GameLogic/PlayerActions/Quests/QuestStartAction.cs
+++ b/src/GameLogic/PlayerActions/Quests/QuestStartAction.cs
@@ -65,7 +65,8 @@ public async ValueTask StartQuestAsync(Player player, short group, short number)
}
else
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Not enough money to proceed", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Quest_Message_NotEnoughMoney", "Not enough money to proceed.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
}
diff --git a/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs b/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs
index caf326a3d..df004045b 100644
--- a/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs
+++ b/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs
@@ -13,6 +13,7 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.Pathfinding;
+using MUnique.OpenMU.GameLogic.NPC;
///
/// Action to attack with a skill which inflicts damage to an area of the current map of the player.
@@ -229,6 +230,9 @@ private static IEnumerable GetTargetsInRange(Player player, Point t
.Where(a => !a.IsAtSafezone())
?? [];
+ // Don't hit own summoned monsters with area skills (classic behavior; requires CTRL on direct hit only).
+ targetsInRange = targetsInRange.Where(a => a is not Monster { SummonedBy: { } owner } || owner != player);
+
if (skill.AreaSkillSettings is { UseFrustumFilter: true } areaSkillSettings)
{
var filter = FrustumFilters.GetOrAdd(areaSkillSettings, static s => new FrustumBasedTargetFilter(s.FrustumStartWidth, s.FrustumEndWidth, s.FrustumDistance));
@@ -269,4 +273,4 @@ private async ValueTask ApplySkillAsync(Player player, SkillEntry skillEntry, IA
await strategy.AfterTargetGotAttackedAsync(player, target, skillEntry, targetAreaCenter, hitInfo).ConfigureAwait(false);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Skills/AreaSkillHitAction.cs b/src/GameLogic/PlayerActions/Skills/AreaSkillHitAction.cs
index e0483071a..d83acc8ce 100644
--- a/src/GameLogic/PlayerActions/Skills/AreaSkillHitAction.cs
+++ b/src/GameLogic/PlayerActions/Skills/AreaSkillHitAction.cs
@@ -4,6 +4,8 @@
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
+using MUnique.OpenMU.GameLogic.NPC;
+
///
/// Action to hit targets with an area skill, which requires explicit hits .
///
@@ -31,10 +33,16 @@ public async ValueTask AttackTargetAsync(Player player, IAttackable target, Skil
// We don't log it as hacker attempt, since the AreaSkillAttackAction already does handle this.
}
+ // Don't allow hitting own summoned monster.
+ if (target is Monster { SummonedBy: { } owner } && owner == player)
+ {
+ return;
+ }
+
if (target.CheckSkillTargetRestrictions(player, skill.Skill))
{
await target.AttackByAsync(player, skill, false).ConfigureAwait(false);
await target.TryApplyElementalEffectsAsync(player, skill).ConfigureAwait(false);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Skills/TargetedSkillDefaultPlugin.cs b/src/GameLogic/PlayerActions/Skills/TargetedSkillDefaultPlugin.cs
index e4dcddb32..901398b97 100644
--- a/src/GameLogic/PlayerActions/Skills/TargetedSkillDefaultPlugin.cs
+++ b/src/GameLogic/PlayerActions/Skills/TargetedSkillDefaultPlugin.cs
@@ -1,15 +1,18 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
+using System.Reflection;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.PlugIns;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.Persistence;
///
/// Action to perform a skill which is explicitly aimed to a target.
@@ -18,6 +21,10 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
[Guid("eb2949fb-5ed2-407e-a4e8-e3015ed5692b")]
public class TargetedSkillDefaultPlugin : TargetedSkillPluginBase
{
+ private static readonly bool SummonDiagEnabled =
+ string.Equals(Environment.GetEnvironmentVariable("SUMMON_DIAG"), "1", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(Environment.GetEnvironmentVariable("SUMMON_DIAG"), "true", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(Environment.GetEnvironmentVariable("SUMMON_DIAG"), "yes", StringComparison.OrdinalIgnoreCase);
private static readonly Dictionary SummonSkillToMonsterMapping = new()
{
{ 30, 26 }, // Goblin
@@ -130,9 +137,60 @@ public override async ValueTask PerformSkillAsync(Player player, IAttackable tar
var effectApplied = false;
if (skill.SkillType == SkillType.SummonMonster)
{
+ MonsterDefinition? baseDefinition = null;
if (SummonSkillToMonsterMapping.TryGetValue(skill.Number, out var monsterNumber)
- && player.GameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == monsterNumber) is { } monsterDefinition)
+ && player.GameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == monsterNumber) is { } mappedDefinition)
{
+ baseDefinition = mappedDefinition;
+ }
+
+ // Allow MonsterNumber override from plug-in configuration even if the plug-in is not active.
+ try
+ {
+ var type = AppDomain.CurrentDomain
+ .GetAssemblies()
+ .SelectMany(a => a.DefinedTypes)
+ .FirstOrDefault(t => typeof(ISummonConfigurationPlugIn).IsAssignableFrom(t)
+ && !t.IsAbstract && !t.IsInterface
+ && this.TryGetSummonKey(t) == skill.Number);
+
+ var typeId = type?.GUID;
+ var plugInConfig = typeId is null ? null : player.GameContext.Configuration.PlugInConfigurations.FirstOrDefault(c => c.TypeId == typeId.Value);
+ var parsed = plugInConfig?.GetConfiguration(player.GameContext.PlugInManager.CustomConfigReferenceHandler);
+ if (parsed is { MonsterNumber: > 0 })
+ {
+ var customDef = player.GameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == (short)parsed.MonsterNumber);
+ if (customDef is { })
+ {
+ baseDefinition = customDef;
+ if (SummonDiagEnabled)
+ {
+ player.Logger.LogInformation($"[SUMMON] Override MonsterNumber by config for skill {skill.Number}: {customDef.Designation} ({customDef.Number})");
+ }
+ }
+ }
+ }
+ catch
+ {
+ // ignore - fall back to default mapping
+ }
+
+ // ✅ pedir el plugin “keyed” por skill.Number
+ var summonPlugin = player.GameContext.PlugInManager
+ .GetStrategy(skill.Number);
+
+ var monsterDefinition = summonPlugin?.CreateSummonMonsterDefinition(player, skill, baseDefinition)
+ ?? baseDefinition;
+
+ // Apply energy scaling as a fallback (and in addition), so it works even if the plugin is not activated
+ monsterDefinition = this.CloneAndScaleSummonDefinition(player, monsterDefinition, skill.Number);
+
+ if (monsterDefinition is not null)
+ {
+ if (SummonDiagEnabled)
+ {
+ player.Logger.LogInformation($"[SUMMON] Creating summon for skill {skill.Number}: {monsterDefinition.Designation} ({monsterDefinition.Number})");
+ }
await player.CreateSummonedMonsterAsync(monsterDefinition).ConfigureAwait(false);
}
}
@@ -140,10 +198,245 @@ public override async ValueTask PerformSkillAsync(Player player, IAttackable tar
{
effectApplied = await this.ApplySkillAsync(player, target, skillEntry!).ConfigureAwait(false);
}
-
await player.ForEachWorldObserverAsync(p => p.ShowSkillAnimationAsync(player, target, skill, effectApplied), true).ConfigureAwait(false);
}
+ private MonsterDefinition? CloneAndScaleSummonDefinition(Player player, MonsterDefinition? baseDefinition, short skillNumber)
+ {
+ if (baseDefinition is null)
+ {
+ return null;
+ }
+
+ var clone = baseDefinition.Clone(player.GameContext.Configuration);
+
+ static void AddAttributeCorrectly(IGameContext context, ICollection? target, MUnique.OpenMU.AttributeSystem.AttributeDefinition? definition, float value)
+ {
+ if (target is null || definition is null)
+ {
+ return;
+ }
+
+ // Resolve AttributeDefinition to the instance within the current game configuration, if present.
+ var resolvedDef = context.Configuration.Attributes.FirstOrDefault(a => a.Id == definition.Id) ?? definition;
+
+ var collectionType = target.GetType();
+ if (collectionType.IsGenericType && collectionType.GetGenericTypeDefinition() == typeof(MUnique.OpenMU.Persistence.CollectionAdapter<,>))
+ {
+ var efItemType = collectionType.GetGenericArguments()[1];
+ if (Activator.CreateInstance(efItemType) is MonsterAttribute efAttr)
+ {
+ efAttr.AttributeDefinition = resolvedDef;
+ efAttr.Value = value;
+ target.Add(efAttr);
+ return;
+ }
+ }
+
+ // Fallback: Plain collection which accepts base type.
+ target.Add(new MonsterAttribute { AttributeDefinition = resolvedDef, Value = value });
+ }
+
+ // Fallback 1: If attributes are not populated, try to take them from any map spawn which uses the same monster number.
+ if (clone.Attributes is null || clone.Attributes.Count == 0)
+ {
+ try
+ {
+ var refDef = player.GameContext.Configuration.Maps
+ .SelectMany(m => m.MonsterSpawns)
+ .Select(s => s.MonsterDefinition)
+ .FirstOrDefault(d => d is { } && d.Number == baseDefinition.Number && d.Attributes?.Any() == true);
+
+ if (refDef?.Attributes?.Any() == true)
+ {
+ foreach (var a in refDef.Attributes)
+ {
+ AddAttributeCorrectly(player.GameContext, clone.Attributes, a.AttributeDefinition, a.Value);
+ }
+
+ if (SummonDiagEnabled)
+ {
+ player.Logger.LogInformation($"[SUMMON] Fallback-from-map loaded {clone.Attributes?.Count ?? 0} attributes for monster {refDef.Designation} ({refDef.Number})");
+ }
+ }
+ else
+ {
+ // Try unified cache (loads once from persistence similar to admin panel data source)
+ if (MonsterDefinitionAttributeCache.TryFillAttributes(player.GameContext, baseDefinition.Number, clone) && SummonDiagEnabled)
+ {
+ player.Logger.LogInformation($"[SUMMON] Fallback-from-cache loaded {clone.Attributes?.Count ?? 0} attributes for monster {baseDefinition.Designation} ({baseDefinition.Number})");
+ }
+ }
+ }
+ catch { }
+ }
+
+ // Fallback 2: Try to load them from persistence (no cache).
+ if (clone.Attributes is null || clone.Attributes.Count == 0)
+ {
+ try
+ {
+ using var ctx = player.GameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(MUnique.OpenMU.DataModel.Configuration.MonsterDefinition), useCache: false);
+ var all = ctx.GetAsync().AsTask().GetAwaiter().GetResult();
+ var dbDef = all.FirstOrDefault(d => d.Number == baseDefinition.Number);
+ if (dbDef?.Attributes?.Any() == true)
+ {
+ foreach (var a in dbDef.Attributes)
+ {
+ AddAttributeCorrectly(player.GameContext, clone.Attributes, a.AttributeDefinition, a.Value);
+ }
+
+ if (SummonDiagEnabled)
+ {
+ player.Logger.LogInformation($"[SUMMON] Fallback loaded {clone.Attributes?.Count ?? 0} attributes for monster {dbDef.Designation} ({dbDef.Number})");
+ }
+ }
+ }
+ catch
+ {
+ // ignore - we scale what we have
+ }
+ }
+
+ // Read energy scaling settings (panel-driven). Prefer in-memory map kept in sync by PlugInManager.
+ // Defaults if not configured.
+ int energyPerStep = 1000;
+ float percentPerStep = 0.05f;
+
+ try
+ {
+ if (MUnique.OpenMU.GameLogic.PlugIns.ElfSummonsConfigCore.Instance.Map.TryGetValue(skillNumber, out var entry))
+ {
+ if (entry.EnergyPerStep > 0)
+ {
+ energyPerStep = entry.EnergyPerStep;
+ }
+
+ if (entry.PercentPerStep > 0)
+ {
+ percentPerStep = entry.PercentPerStep;
+ }
+ }
+ else
+ {
+ // Fallback: Read directly from plugin configuration store.
+ var type = AppDomain.CurrentDomain
+ .GetAssemblies()
+ .SelectMany(a => a.DefinedTypes)
+ .FirstOrDefault(t => typeof(ISummonConfigurationPlugIn).IsAssignableFrom(t)
+ && !t.IsAbstract && !t.IsInterface
+ && this.TryGetSummonKey(t) == skillNumber);
+
+ var typeId = type?.GUID;
+ var plugInConfig = typeId is null ? null : player.GameContext.Configuration.PlugInConfigurations.FirstOrDefault(c => c.TypeId == typeId.Value);
+ if (plugInConfig is not null)
+ {
+ var parsed = plugInConfig.GetConfiguration(player.GameContext.PlugInManager.CustomConfigReferenceHandler);
+ if (parsed is not null)
+ {
+ if (parsed.EnergyPerStep > 0)
+ {
+ energyPerStep = parsed.EnergyPerStep;
+ }
+
+ if (parsed.PercentPerStep > 0)
+ {
+ percentPerStep = parsed.PercentPerStep;
+ }
+ }
+ }
+ }
+ }
+ catch
+ {
+ // ignore and use defaults
+ }
+
+ var energy = player.Attributes?[Stats.TotalEnergy] ?? 0;
+ var steps = energyPerStep > 0 ? (int)(energy / energyPerStep) : 0;
+ var energyScale = 1.0f + Math.Max(0, steps) * Math.Max(0, percentPerStep);
+ if (SummonDiagEnabled)
+ {
+ player.Logger.LogInformation($"[SUMMON] Energy={energy}, steps={steps}, energyPerStep={energyPerStep}, percentPerStep={percentPerStep}, scale={energyScale:0.###}");
+ try
+ {
+ var count = clone.Attributes?.Count ?? 0;
+ var present = clone.Attributes?.Select(a => a.AttributeDefinition?.Designation ?? a.AttributeDefinition?.Id.ToString() ?? "")
+ .Take(10).ToArray() ?? Array.Empty();
+ player.Logger.LogInformation($"[SUMMON] AttrCount={count}, sample=[{string.Join(", ", present)}]");
+ player.Logger.LogInformation($"[SUMMON] StatIds: MaxHP={Stats.MaximumHealth.Id}, DefBase={Stats.DefenseBase.Id}, PhysMin={Stats.MinimumPhysBaseDmg.Id}, PhysMax={Stats.MaximumPhysBaseDmg.Id}");
+ }
+ catch { }
+ }
+
+ if (Math.Abs(energyScale - 1.0f) < float.Epsilon)
+ {
+ return clone; // nothing to scale
+ }
+
+ float GetValue(MUnique.OpenMU.AttributeSystem.AttributeDefinition stat)
+ {
+ var attr = clone.Attributes?.FirstOrDefault(a => a.AttributeDefinition == stat);
+ return attr?.Value ?? 0;
+ }
+
+ void Scale(MUnique.OpenMU.AttributeSystem.AttributeDefinition stat)
+ {
+ var attr = clone.Attributes?.FirstOrDefault(a => a.AttributeDefinition == stat);
+ if (attr is not null)
+ {
+ attr.Value *= energyScale;
+ }
+ }
+
+ // HP
+ Scale(Stats.MaximumHealth);
+ // Defense base
+ Scale(Stats.DefenseBase);
+ // Damage bases
+ Scale(Stats.MinimumPhysBaseDmg);
+ Scale(Stats.MaximumPhysBaseDmg);
+ Scale(Stats.MinimumWizBaseDmg);
+ Scale(Stats.MaximumWizBaseDmg);
+ Scale(Stats.MinimumCurseBaseDmg);
+ Scale(Stats.MaximumCurseBaseDmg);
+ // Rates to reduce MISS and improve survivability
+ Scale(Stats.AttackRatePvm);
+ Scale(Stats.DefenseRatePvm);
+ Scale(Stats.DefenseRatePvp);
+
+ if (SummonDiagEnabled)
+ {
+ player.Logger.LogInformation(
+ $"[SUMMON] Stats after scale: HP={GetValue(Stats.MaximumHealth):0}, DefBase={GetValue(Stats.DefenseBase):0}, " +
+ $"PhysMin/Max={GetValue(Stats.MinimumPhysBaseDmg):0}/{GetValue(Stats.MaximumPhysBaseDmg):0}, " +
+ $"WizMin/Max={GetValue(Stats.MinimumWizBaseDmg):0}/{GetValue(Stats.MaximumWizBaseDmg):0}, " +
+ $"CurseMin/Max={GetValue(Stats.MinimumCurseBaseDmg):0}/{GetValue(Stats.MaximumCurseBaseDmg):0}, " +
+ $"AtkRatePvM={GetValue(Stats.AttackRatePvm):0}, DefRatePvM={GetValue(Stats.DefenseRatePvm):0}, DefRatePvP={GetValue(Stats.DefenseRatePvp):0}");
+ }
+
+ return clone;
+ }
+
+ private short TryGetSummonKey(Type pluginType)
+ {
+ try
+ {
+ var instance = Activator.CreateInstance(pluginType);
+ var prop = pluginType.GetProperty("Key", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+ if (prop?.GetValue(instance) is short k)
+ {
+ return k;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return short.MinValue;
+ }
+
///
/// Determines the targets of the skill. It can be overridden by derived classes to provide custom target selection logic.
///
@@ -155,6 +448,18 @@ protected virtual IEnumerable DetermineTargets(Player player, IAtta
{
if (skill.Target == SkillTarget.ImplicitPlayer)
{
+ // Include own summon for buffs/regeneration
+ if (skill.SkillType is SkillType.Buff or SkillType.Regeneration)
+ {
+ var list = new List { player };
+ var summon = player.Summon?.Item1;
+ if (summon is { IsAlive: true })
+ {
+ list.Add(summon);
+ }
+ return list;
+ }
+
return player.GetAsEnumerable();
}
@@ -162,9 +467,43 @@ protected virtual IEnumerable DetermineTargets(Player player, IAtta
{
if (player.Party != null)
{
+ if (skill.SkillType is SkillType.Buff or SkillType.Regeneration)
+ {
+ var result = new List();
+ foreach (var member in player.Party.PartyList.OfType())
+ {
+ if (player.Observers.Contains(member))
+ {
+ result.Add(member);
+ }
+
+ var memberSummon = member.Summon?.Item1;
+ if (memberSummon is { IsAlive: true })
+ {
+ result.Add(memberSummon);
+ }
+ }
+
+ if (result.Count > 0)
+ {
+ return result;
+ }
+ }
+
return player.Party.PartyList.OfType().Where(p => player.Observers.Contains((IWorldObserver)p));
}
+ if (skill.SkillType is SkillType.Buff or SkillType.Regeneration)
+ {
+ var list = new List { player };
+ var summon = player.Summon?.Item1;
+ if (summon is { IsAlive: true })
+ {
+ list.Add(summon);
+ }
+ return list;
+ }
+
return player.GetAsEnumerable();
}
@@ -255,6 +594,13 @@ private async ValueTask ApplySkillAsync(Player player, IAttackable targete
{
if (skill.SkillType == SkillType.DirectHit || skill.SkillType == SkillType.CastleSiegeSkill)
{
+ // Block direct hits against own summon (client requires CTRL to send such an action for "basic"),
+ // keeping classic behavior. Aggro against owner is blocked in SummonedMonsterIntelligence anyway.
+ if (target is Monster { SummonedBy: { } owner } && owner == player)
+ {
+ continue;
+ }
+
if (player.Attributes![Stats.AmmunitionConsumptionRate] > player.Attributes[Stats.AmmunitionAmount])
{
break;
@@ -305,4 +651,4 @@ private async ValueTask ApplySkillAsync(Player player, IAttackable targete
return success;
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/TalkNpcAction.cs b/src/GameLogic/PlayerActions/TalkNpcAction.cs
index c2e23c086..948c9ef24 100644
--- a/src/GameLogic/PlayerActions/TalkNpcAction.cs
+++ b/src/GameLogic/PlayerActions/TalkNpcAction.cs
@@ -80,7 +80,12 @@ private async ValueTask ShowDialogOfOpenedNpcAsync(Player player)
}
else
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync($"Talking to this NPC ({npcStats.Number}, {npcStats.Designation}) is not implemented yet.", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage(
+ "Npc_Message_NotImplemented",
+ "Talking to this NPC ({0}, {1}) is not implemented yet.",
+ npcStats.Number,
+ npcStats.Designation ?? string.Empty);
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
}
await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
@@ -151,7 +156,8 @@ private async ValueTask ShowLegacyQuestDialogAsync(Player player)
if (!quests.Any())
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageOfObjectAsync("I have no quests for you.", player.OpenedNpc)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Npc_LegacyQuest_None", "I have no quests for you.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageOfObjectAsync(message, player.OpenedNpc)).ConfigureAwait(false);
player.OpenedNpc = null;
await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
return;
@@ -159,9 +165,8 @@ private async ValueTask ShowLegacyQuestDialogAsync(Player player)
if (quests.All(quest => quest.MinimumCharacterLevel > player.Level))
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageOfObjectAsync(
- "I have nothing to do for you. Come back with more power.",
- player.OpenedNpc)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Npc_LegacyQuest_TooLowLevel", "I have nothing to do for you. Come back with more power.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageOfObjectAsync(message, player.OpenedNpc)).ConfigureAwait(false);
player.OpenedNpc = null;
await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
return;
@@ -172,9 +177,8 @@ await player.InvokeViewPlugInAsync(p => p.ShowMessag
var questState = player.GetQuestState(questGroup ?? 0);
if (questState?.LastFinishedQuest?.Number >= maxQuestNumber)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageOfObjectAsync(
- "I have nothing to do for you. You solved all my quests already.",
- player.OpenedNpc)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Npc_LegacyQuest_AllCompleted", "I have nothing to do for you. You solved all my quests already.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageOfObjectAsync(message, player.OpenedNpc)).ConfigureAwait(false);
player.OpenedNpc = null;
await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
return;
@@ -187,16 +191,18 @@ private async ValueTask IsPlayedAllowedToCreateGuildAsync(Player player)
{
if (player.Level < 100)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageOfObjectAsync("Your level should be at least level 100", player.OpenedNpc!)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Npc_GuildMaster_LevelRequirement", "Your level should be at least level 100.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageOfObjectAsync(message, player.OpenedNpc!)).ConfigureAwait(false);
return false;
}
if (player.GuildStatus != null)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageOfObjectAsync("You already belong to a guild", player.OpenedNpc!)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Npc_GuildMaster_AlreadyInGuild", "You already belong to a guild.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageOfObjectAsync(message, player.OpenedNpc!)).ConfigureAwait(false);
return false;
}
return true;
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlayerActions/Trade/TradeMoneyAction.cs b/src/GameLogic/PlayerActions/Trade/TradeMoneyAction.cs
index f2665c37c..fcca87fca 100644
--- a/src/GameLogic/PlayerActions/Trade/TradeMoneyAction.cs
+++ b/src/GameLogic/PlayerActions/Trade/TradeMoneyAction.cs
@@ -24,7 +24,8 @@ public async ValueTask TradeMoneyAsync(Player player, uint moneyAmount)
// Check if Trade is open
if (player.PlayerState.CurrentState != PlayerState.TradeOpened)
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Uncheck trade accept button first", MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Trade_Message_UncheckAccept", "Uncheck the trade accept button first.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
diff --git a/src/GameLogic/PlayerActions/WarpGateAction.cs b/src/GameLogic/PlayerActions/WarpGateAction.cs
index 7c8a336f9..f0901521e 100644
--- a/src/GameLogic/PlayerActions/WarpGateAction.cs
+++ b/src/GameLogic/PlayerActions/WarpGateAction.cs
@@ -46,7 +46,8 @@ private async ValueTask IsWarpLegitAsync(Player player, EnterGate? enterGa
var requirement = player.SelectedCharacter?.GetEffectiveMoveLevelRequirement(enterGate.LevelRequirement);
if (requirement > player.Attributes![Stats.Level])
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Your level is too low to enter this map.", Interfaces.MessageType.BlueNormal)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("WarpGate_Message_LevelTooLow", "Your level is too low to enter this map.");
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, Interfaces.MessageType.BlueNormal)).ConfigureAwait(false);
return false;
}
@@ -73,4 +74,4 @@ private bool IsXInRange(Point currentPosition, Gate gate, byte inaccuracy) => cu
private bool IsYInRange(Point currentPosition, Gate gate, byte inaccuracy) => currentPosition.Y >= gate.Y1 - inaccuracy
&& currentPosition.Y <= gate.Y2 + inaccuracy;
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlugIns/AutoBroadcastMessagesPlugIn.cs b/src/GameLogic/PlugIns/AutoBroadcastMessagesPlugIn.cs
new file mode 100644
index 000000000..f45afb5a2
--- /dev/null
+++ b/src/GameLogic/PlugIns/AutoBroadcastMessagesPlugIn.cs
@@ -0,0 +1,198 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.PlugIns;
+
+using System.Collections.Concurrent;
+using System.ComponentModel.DataAnnotations;
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.PlugIns;
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.DataModel.Composition;
+
+///
+/// Periodically broadcasts golden-center messages to all players.
+/// Each message has its own interval and optional initial offset to avoid collisions.
+/// Configure messages in Admin Panel → Plugins.
+///
+[PlugIn("Auto Broadcast Messages", "Sends configurable golden messages globally at individual intervals.")]
+[Guid("F1A4CF77-7B1C-420B-9B0F-3AAB7F8E7A3D")]
+public class AutoBroadcastMessagesPlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration
+{
+ private static readonly ConcurrentDictionary States = new();
+
+ private AutoBroadcastMessagesConfiguration? _configuration;
+
+ ///
+ public AutoBroadcastMessagesConfiguration? Configuration
+ {
+ get => this._configuration;
+ set
+ {
+ this._configuration = value;
+ this.RecalculateSchedules();
+ }
+ }
+
+ ///
+ public object CreateDefaultConfig()
+ {
+ // Two example messages with different intervals and offsets.
+ var config = new AutoBroadcastMessagesConfiguration();
+ config.Messages.Add(new BroadcastMessageEntry
+ {
+ Message = "¡Gracias por jugar!",
+ Interval = TimeSpan.FromMinutes(10),
+ InitialDelay = TimeSpan.FromMinutes(1),
+ MessageType = MessageType.GoldenCenter,
+ Enabled = true,
+ });
+ config.Messages.Add(new BroadcastMessageEntry
+ {
+ Message = "Síguenos en Discord para novedades.",
+ Interval = TimeSpan.FromMinutes(15),
+ InitialDelay = TimeSpan.FromMinutes(3),
+ MessageType = MessageType.GoldenCenter,
+ Enabled = true,
+ });
+
+ return config;
+ }
+
+ ///
+ public void ForceStart() => this.RecalculateSchedules();
+
+ ///
+ public async ValueTask ExecuteTaskAsync(GameContext gameContext)
+ {
+ var config = this.Configuration ??= (AutoBroadcastMessagesConfiguration)this.CreateDefaultConfig();
+ if (config.Messages.Count == 0)
+ {
+ return;
+ }
+
+ var state = States.GetOrAdd(gameContext, _ => new PerContextState());
+ var now = DateTime.UtcNow;
+
+ // Ensure stable schedule for each message index.
+ for (var i = 0; i < config.Messages.Count; i++)
+ {
+ var entry = config.Messages[i];
+ if (!entry.Enabled || string.IsNullOrWhiteSpace(entry.Message))
+ {
+ continue;
+ }
+
+ if (!state.NextRunByIndex.TryGetValue(i, out var next))
+ {
+ // First schedule for this entry.
+ var first = now + entry.InitialDelay;
+ state.NextRunByIndex[i] = first;
+ continue;
+ }
+
+ if (state.ForceNow || now >= next)
+ {
+ try
+ {
+ await gameContext.SendGlobalMessageAsync(entry.Message, entry.MessageType).ConfigureAwait(false);
+ }
+ catch
+ {
+ // ignore send errors per tick to keep scheduler stable.
+ }
+
+ // Schedule next run based on interval.
+ state.NextRunByIndex[i] = now + entry.Interval;
+ }
+ }
+
+ state.ForceNow = false;
+ }
+
+ private void RecalculateSchedules()
+ {
+ var config = this.Configuration;
+ if (config is null)
+ {
+ return;
+ }
+
+ var now = DateTime.UtcNow;
+ foreach (var state in States.Values)
+ {
+ state.NextRunByIndex.Clear();
+ for (var i = 0; i < config.Messages.Count; i++)
+ {
+ var entry = config.Messages[i];
+ if (!entry.Enabled || string.IsNullOrWhiteSpace(entry.Message))
+ {
+ continue;
+ }
+
+ state.NextRunByIndex[i] = now + entry.InitialDelay;
+ }
+ state.ForceNow = false;
+ }
+ }
+
+ private class PerContextState
+ {
+ public ConcurrentDictionary NextRunByIndex { get; } = new();
+
+ public bool ForceNow { get; set; }
+ }
+}
+
+///
+/// Configuration for .
+///
+public class AutoBroadcastMessagesConfiguration
+{
+ ///
+ /// Gets or sets the list of messages with their own schedule.
+ ///
+ [MemberOfAggregate]
+ [ScaffoldColumn(true)]
+ [Display(Name = "Messages")]
+ public IList Messages { get; set; } = new List();
+}
+
+///
+/// A scheduled broadcast message entry.
+///
+public class BroadcastMessageEntry
+{
+ ///
+ /// Gets or sets if this message is enabled.
+ ///
+ [Display(Name = "Enabled")]
+ public bool Enabled { get; set; } = true;
+
+ ///
+ /// Gets or sets the message text.
+ ///
+ [Display(Name = "Message Text")]
+ public string Message { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the interval between broadcasts.
+ ///
+ [Display(Name = "Interval")]
+ public TimeSpan Interval { get; set; } = TimeSpan.FromMinutes(10);
+
+ ///
+ /// Gets or sets an initial delay before the first broadcast.
+ /// Useful to avoid overlaps with other messages.
+ ///
+ [Display(Name = "Initial Delay")]
+ public TimeSpan InitialDelay { get; set; } = TimeSpan.Zero;
+
+ ///
+ /// Gets or sets the message type (e.g. GoldenCenter, BlueNormal).
+ ///
+ [Display(Name = "Type")]
+ public MessageType MessageType { get; set; } = MessageType.GoldenCenter;
+}
diff --git a/src/GameLogic/PlugIns/ChatCommands/AddStatChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/AddStatChatCommandPlugIn.cs
index 6146025c9..ca8b102e8 100644
--- a/src/GameLogic/PlugIns/ChatCommands/AddStatChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/AddStatChatCommandPlugIn.cs
@@ -14,8 +14,8 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin which handles the command to add stat points.
///
[Guid("042EC5C6-27C8-4E00-A48B-C5458EDEA0BC")]
-[PlugIn("Add Stat chat command", "Handles the chat command '/add (ene|agi|vit|str|cmd) (amount)'. Adds the specified amount of stat points to the specified attribute of the character.")]
-[ChatCommandHelp(Command, "Adds the specified amount of stat points to the specified attribute of the character.", typeof(Arguments), MinimumStatus)]
+[PlugIn("Add Stat chat command", "Maneja el comando '/add (ene|agi|vit|str|cmd) (cantidad)'. Suma la cantidad indicada de puntos al atributo especificado del personaje.")]
+[ChatCommandHelp(Command, "Suma la cantidad indicada de puntos al atributo especificado del personaje.", typeof(Arguments), MinimumStatus)]
public class AddStatChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/add";
@@ -51,7 +51,8 @@ public virtual async ValueTask HandleCommandAsync(Player player, string command)
if (player.CurrentMiniGame is not null)
{
- await player.ShowMessageAsync("Adding multiple points is not allowed when playing a mini game.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Chat_AddStat_NotAllowedDuringMiniGame", "Adding multiple points is not allowed during a mini-game.");
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return;
}
@@ -72,12 +73,12 @@ private AttributeDefinition GetAttribute(Player player, string? statType)
"vit" => Stats.BaseVitality,
"ene" => Stats.BaseEnergy,
"cmd" => Stats.BaseLeadership,
- _ => throw new ArgumentException($"Unknown stat: '{statType}'."),
+ _ => throw new ArgumentException(player.GetLocalizedMessage("Chat_AddStat_UnknownAttribute", "Unknown attribute: '{0}'.", statType)),
};
if (player.SelectedCharacter!.Attributes.All(sa => sa.Definition != attribute))
{
- throw new ArgumentException($"The character has no stat attribute '{statType}'.");
+ throw new ArgumentException(player.GetLocalizedMessage("Chat_AddStat_AttributeMissing", "The character doesn't have the attribute '{0}'.", statType));
}
return attribute;
@@ -90,4 +91,4 @@ private class Arguments : ArgumentsBase
public ushort Amount { get; set; }
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlugIns/ChatCommands/Arguments/ItemStackChatCommandArgs.cs b/src/GameLogic/PlugIns/ChatCommands/Arguments/ItemStackChatCommandArgs.cs
new file mode 100644
index 000000000..697bb9bba
--- /dev/null
+++ b/src/GameLogic/PlugIns/ChatCommands/Arguments/ItemStackChatCommandArgs.cs
@@ -0,0 +1,28 @@
+// Copyright (c) MUnique. Licensed under the MIT license.
+
+namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands.Arguments;
+
+///
+/// Arguments for the /itemstack command.
+///
+public class ItemStackChatCommandArgs : ArgumentsBase
+{
+ [Argument("group")]
+ public byte Group { get; set; }
+
+ [Argument("number")]
+ public short Number { get; set; }
+
+ ///
+ /// Gets or sets the desired amount for the stack or the number of pieces to create.
+ ///
+ [Argument("count")]
+ public int Count { get; set; }
+
+ ///
+ /// Optional item level to set on created items (default 0).
+ ///
+ [Argument("lvl", false)]
+ public byte Level { get; set; }
+}
+
diff --git a/src/GameLogic/PlugIns/ChatCommands/ClearInventoryChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/ClearInventoryChatCommandPlugIn.cs
index 07ba25ca1..a5719a3d8 100644
--- a/src/GameLogic/PlugIns/ChatCommands/ClearInventoryChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/ClearInventoryChatCommandPlugIn.cs
@@ -20,13 +20,13 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin which clears a character's inventory.
///
[Guid("1E895A6F-3056-4A78-BA64-96E24363B8BC")]
-[PlugIn("Clear Inventory chat command", "Clears inventory. Usage: /clearinv (optional:character)")]
-[ChatCommandHelp(Command, "Clears inventory.", null, MinimumStatus)]
+[PlugIn("Clear Inventory chat command", "Limpia el inventario. Uso: /clearinv (opcional:personaje)")]
+[ChatCommandHelp(Command, "Limpia el inventario.", null, MinimumStatus)]
public class ClearInventoryChatCommandPlugIn : ChatCommandPlugInBase, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisabledByDefault
{
private const string Command = "/clearinv";
private const CharacterStatus MinimumStatus = CharacterStatus.Normal;
- private const string CharacterNotFoundMessage = "Character '{0}' not found.";
+ private const string CharacterNotFoundMessage = "Personaje '{0}' no encontrado.";
private const int ConfirmationTimeoutSeconds = 10;
private readonly Dictionary pendingConfirmations = new();
diff --git a/src/GameLogic/PlugIns/ChatCommands/CommandExtensions.cs b/src/GameLogic/PlugIns/ChatCommands/CommandExtensions.cs
index 5b883276c..3608f6154 100644
--- a/src/GameLogic/PlugIns/ChatCommands/CommandExtensions.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/CommandExtensions.cs
@@ -49,7 +49,7 @@ public static T ParseArguments(this string command)
if (arguments.Count < requiredArgumentCount)
{
- throw new ArgumentException($"The command needs {requiredArgumentCount} arguments and was given {arguments.Count}.", nameof(command));
+ throw new ArgumentException($"El comando requiere {requiredArgumentCount} argumentos y se recibieron {arguments.Count}.", nameof(command));
}
for (var i = 0; i < Math.Min(arguments.Count, properties.Count); i++)
@@ -166,11 +166,11 @@ private static void ReadNamedArguments(object instance, IList prop
return;
}
- // One or many required properties were not used
+ // Uno o varios argumentos obligatorios no fueron usados
var stringBuilder = new StringBuilder();
foreach (var requiredProperty in requiredProperties)
{
- stringBuilder.AppendLine($"The required argument named {requiredProperty.Name} was not used.");
+ stringBuilder.AppendLine($"El argumento obligatorio '{requiredProperty.Name}' no fue especificado.");
}
throw new ArgumentException(stringBuilder.ToString());
@@ -190,7 +190,7 @@ private static void SetPropertyValue(object instance, PropertyInfo propertyInfo,
}
catch
{
- throw new ArgumentException($"The argument {propertyInfo.Name} was given a invalid type, it expects the value to be of the type {propertyInfo.PropertyType.Name}.");
+ throw new ArgumentException($"El argumento {propertyInfo.Name} tiene un tipo inválido, se esperaba {propertyInfo.PropertyType.Name}.");
}
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlugIns/ChatCommands/GetLevelChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/GetLevelChatCommandPlugIn.cs
index 73679950c..b47f261c2 100644
--- a/src/GameLogic/PlugIns/ChatCommands/GetLevelChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/GetLevelChatCommandPlugIn.cs
@@ -15,14 +15,14 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin to get a character's level.
///
[Guid("9D5C8FFE-EC32-48AC-8B6F-BB361AD184E5")]
-[PlugIn("Get level command", "Gets level of a player. Usage: /getlevel (optional:character)")]
-[ChatCommandHelp(Command, "Gets level of a player. Usage: /getlevel (optional:character)", null)]
+[PlugIn("Get level command", "Obtiene el nivel de un jugador. Uso: /getlevel (opcional:personaje)")]
+[ChatCommandHelp(Command, "Obtiene el nivel de un jugador. Uso: /getlevel (opcional:personaje)", null)]
public class GetLevelChatCommandPlugIn : ChatCommandPlugInBase, IDisabledByDefault
{
private const string Command = "/getlevel";
private const CharacterStatus MinimumStatus = CharacterStatus.GameMaster;
- private const string CharacterNotFoundMessage = "Character '{0}' not found.";
- private const string LevelGetMessage = "Level of '{0}': {1}.";
+ private const string CharacterNotFoundMessage = "Personaje '{0}' no encontrado.";
+ private const string LevelGetMessage = "Nivel de '{0}': {1}.";
///
public override string Key => Command;
diff --git a/src/GameLogic/PlugIns/ChatCommands/GetLevelUpPointsChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/GetLevelUpPointsChatCommandPlugIn.cs
index 28d553515..e02f665a9 100644
--- a/src/GameLogic/PlugIns/ChatCommands/GetLevelUpPointsChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/GetLevelUpPointsChatCommandPlugIn.cs
@@ -14,14 +14,14 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin to get a character's level-up points.
///
[Guid("E4D65354-CCD2-4960-BDCA-D4582A57BBCB")]
-[PlugIn("Get level up points command", "Gets level up points of a player. Usage: /getleveluppoints (optional:character)")]
-[ChatCommandHelp(Command, "Gets level up points of a player. Usage: /getleveluppoints (optional:character)", null)]
+[PlugIn("Get level up points command", "Obtiene los puntos de nivel de un jugador. Uso: /getleveluppoints (opcional:personaje)")]
+[ChatCommandHelp(Command, "Obtiene los puntos de nivel de un jugador. Uso: /getleveluppoints (opcional:personaje)", null)]
public class GetLevelUpPointsChatCommandPlugIn : ChatCommandPlugInBase, IDisabledByDefault
{
private const string Command = "/getleveluppoints";
private const CharacterStatus MinimumStatus = CharacterStatus.GameMaster;
- private const string CharacterNotFoundMessage = "Character '{0}' not found.";
- private const string LevelUpPointsGetMessage = "Level-up points of '{0}': {1}.";
+ private const string CharacterNotFoundMessage = "Personaje '{0}' no encontrado.";
+ private const string LevelUpPointsGetMessage = "Puntos de nivel de '{0}': {1}.";
///
public override string Key => Command;
diff --git a/src/GameLogic/PlugIns/ChatCommands/GetMasterLevelChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/GetMasterLevelChatCommandPlugIn.cs
index c8d39e4d6..ac1037f99 100644
--- a/src/GameLogic/PlugIns/ChatCommands/GetMasterLevelChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/GetMasterLevelChatCommandPlugIn.cs
@@ -15,14 +15,14 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin to get a character's master level.
///
[Guid("4CED4BF8-9D91-47F9-82DE-51E2646F77C8")]
-[PlugIn("Get master level command", "Gets master level of a player. Usage: /getmasterlevel (optional:character)")]
-[ChatCommandHelp(Command, "Gets master level of a player. Usage: /getmasterlevel (optional:character)", null)]
+[PlugIn("Get master level command", "Obtiene el nivel maestro de un jugador. Uso: /getmasterlevel (opcional:personaje)")]
+[ChatCommandHelp(Command, "Obtiene el nivel maestro de un jugador. Uso: /getmasterlevel (opcional:personaje)", null)]
public class GetMasterLevelChatCommandPlugIn : ChatCommandPlugInBase, IDisabledByDefault
{
private const string Command = "/getmasterlevel";
private const CharacterStatus MinimumStatus = CharacterStatus.GameMaster;
- private const string CharacterNotFoundMessage = "Character '{0}' not found.";
- private const string MasterLevelGetMessage = "Master level of '{0}': {1}.";
+ private const string CharacterNotFoundMessage = "Personaje '{0}' no encontrado.";
+ private const string MasterLevelGetMessage = "Nivel maestro de '{0}': {1}.";
///
public override string Key => Command;
diff --git a/src/GameLogic/PlugIns/ChatCommands/GetMasterLevelUpPointsChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/GetMasterLevelUpPointsChatCommandPlugIn.cs
index c5bcb433d..a70843471 100644
--- a/src/GameLogic/PlugIns/ChatCommands/GetMasterLevelUpPointsChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/GetMasterLevelUpPointsChatCommandPlugIn.cs
@@ -14,14 +14,14 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin to get a character's master level-up points.
///
[Guid("8ACCF267-F5F3-4003-B4C3-536ACCB5181D")]
-[PlugIn("Get master level up points command", "Gets master level up points of a player. Usage: /getmasterleveluppoints (optional:character)")]
-[ChatCommandHelp(Command, "Gets master level up points of a player. Usage: /getmasterleveluppoints (optional:character)", null)]
+[PlugIn("Get master level up points command", "Obtiene los puntos de nivel maestro de un jugador. Uso: /getmasterleveluppoints (opcional:personaje)")]
+[ChatCommandHelp(Command, "Obtiene los puntos de nivel maestro de un jugador. Uso: /getmasterleveluppoints (opcional:personaje)", null)]
public class GetMasterLevelUpPointsChatCommandPlugIn : ChatCommandPlugInBase, IDisabledByDefault
{
private const string Command = "/getmasterleveluppoints";
private const CharacterStatus MinimumStatus = CharacterStatus.GameMaster;
- private const string CharacterNotFoundMessage = "Character '{0}' not found.";
- private const string MasterLevelUpPointsGetMessage = "Master level-up points of '{0}': {1}.";
+ private const string CharacterNotFoundMessage = "Personaje '{0}' no encontrado.";
+ private const string MasterLevelUpPointsGetMessage = "Puntos de nivel maestro de '{0}': {1}.";
///
public override string Key => Command;
diff --git a/src/GameLogic/PlugIns/ChatCommands/GetMoneyChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/GetMoneyChatCommandPlugIn.cs
index b0bd8b811..8fc4fa4a9 100644
--- a/src/GameLogic/PlugIns/ChatCommands/GetMoneyChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/GetMoneyChatCommandPlugIn.cs
@@ -14,14 +14,14 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin to get a character's money.
///
[Guid("207F5872-33AB-4764-B67F-95AB7C6313E3")]
-[PlugIn("Get money command", "Gets money of a player. Usage: /getmoney (optional:character)")]
-[ChatCommandHelp(Command, "Gets money of a player. Usage: /getmoney (optional:character)", null)]
+[PlugIn("Get money command", "Obtiene el Zen de un jugador. Uso: /getmoney (opcional:personaje)")]
+[ChatCommandHelp(Command, "Obtiene el Zen de un jugador. Uso: /getmoney (opcional:personaje)", null)]
public class GetMoneyChatCommandPlugIn : ChatCommandPlugInBase, IDisabledByDefault
{
private const string Command = "/getmoney";
private const CharacterStatus MinimumStatus = CharacterStatus.GameMaster;
- private const string CharacterNotFoundMessage = "Character '{0}' not found.";
- private const string MoneyGetMessage = "Money of '{0}': {1}.";
+ private const string CharacterNotFoundMessage = "Personaje '{0}' no encontrado.";
+ private const string MoneyGetMessage = "Zen de '{0}': {1}.";
///
public override string Key => Command;
diff --git a/src/GameLogic/PlugIns/ChatCommands/GetResetsChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/GetResetsChatCommandPlugIn.cs
index 8b30198cb..e6d123444 100644
--- a/src/GameLogic/PlugIns/ChatCommands/GetResetsChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/GetResetsChatCommandPlugIn.cs
@@ -15,15 +15,15 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin which sets a character's resets.
///
[Guid("26ACF6A9-346A-49DF-8583-EA610F6E3AEA")]
-[PlugIn("Get resets command", "Gets resets of a player. Usage: /getresets (optional:character)")]
-[ChatCommandHelp(Command, "Gets resets of a player. Usage: /getresets (optional:character)", null)]
+[PlugIn("Get resets command", "Obtiene los resets de un jugador. Uso: /getresets (opcional:personaje)")]
+[ChatCommandHelp(Command, "Obtiene los resets de un jugador. Uso: /getresets (opcional:personaje)", null)]
public class GetResetsChatCommandPlugIn : ChatCommandPlugInBase, IDisabledByDefault
{
private const string Command = "/getresets";
private const CharacterStatus MinimumStatus = CharacterStatus.GameMaster;
- private const string ResetPluginDisabledMessage = "The reset system is not enabled on this server.";
- private const string CharacterNotFoundMessage = "Character '{0}' not found.";
- private const string ResetsGetMessage = "Resets of '{0}': {1}.";
+ private const string ResetPluginDisabledMessage = "El sistema de resets no está habilitado en este servidor.";
+ private const string CharacterNotFoundMessage = "Personaje '{0}' no encontrado.";
+ private const string ResetsGetMessage = "Resets de '{0}': {1}.";
///
public override string Key => Command;
diff --git a/src/GameLogic/PlugIns/ChatCommands/GetStatChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/GetStatChatCommandPlugIn.cs
index cc281427d..89f60cb71 100644
--- a/src/GameLogic/PlugIns/ChatCommands/GetStatChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/GetStatChatCommandPlugIn.cs
@@ -14,14 +14,14 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin which handles the command to get stat points.
///
[Guid("F8CACA47-D486-45AE-814F-C6218AD87652")]
-[PlugIn("Get Stat chat command", "Get stat points. Usage: /get (ene|agi|vit|str|cmd) (optional:character)")]
-[ChatCommandHelp(Command, "Get stat points. Usage: /get (ene|agi|vit|str|cmd) (optional:character)", typeof(Arguments), MinimumStatus)]
+[PlugIn("Get Stat chat command", "Obtiene puntos de atributo. Uso: /get (ene|agi|vit|str|cmd) (opcional:personaje)")]
+[ChatCommandHelp(Command, "Obtiene puntos de atributo. Uso: /get (ene|agi|vit|str|cmd) (opcional:personaje)", typeof(Arguments), MinimumStatus)]
public class GetStatChatCommandPlugIn : ChatCommandPlugInBase, IDisabledByDefault
{
private const string Command = "/get";
private const CharacterStatus MinimumStatus = CharacterStatus.GameMaster;
- private const string CharacterNotFoundMessage = "Character '{0}' not found.";
- private const string StatGetMessage = "Stat of '{0}': {1}.";
+ private const string CharacterNotFoundMessage = "Personaje '{0}' no encontrado.";
+ private const string StatGetMessage = "Atributo de '{0}': {1}.";
///
public override string Key => Command;
@@ -74,12 +74,12 @@ private AttributeDefinition GetAttribute(Character selectedCharacter, string? st
"vit" => Stats.BaseVitality,
"ene" => Stats.BaseEnergy,
"cmd" => Stats.BaseLeadership,
- _ => throw new ArgumentException($"Unknown stat: '{statType}'."),
+ _ => throw new ArgumentException($"Atributo desconocido: '{statType}'."),
};
if (selectedCharacter.Attributes.All(sa => sa.Definition != attribute))
{
- throw new ArgumentException($"The character has no stat attribute '{statType}'.");
+ throw new ArgumentException($"El personaje no tiene el atributo '{statType}'.");
}
return attribute;
@@ -101,4 +101,4 @@ public class Arguments : ArgumentsBase
///
public string? CharacterName { get; set; }
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlugIns/ChatCommands/GuildMoveChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/GuildMoveChatCommandPlugIn.cs
index 60e44e005..59b67976f 100644
--- a/src/GameLogic/PlugIns/ChatCommands/GuildMoveChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/GuildMoveChatCommandPlugIn.cs
@@ -41,8 +41,17 @@ await gameServerContext.ForEachGuildPlayerAsync(guildId, async guildPlayer =>
if (!guildPlayer.Name.Equals(gameMaster.Name))
{
- await this.ShowMessageToAsync(guildPlayer, "You have been moved by the game master.").ConfigureAwait(false);
- await this.ShowMessageToAsync(gameMaster, $"[{this.Key}] {guildPlayer.Name} has been moved to {exitGate!.Map!.Name} at {guildPlayer.Position.X}, {guildPlayer.Position.Y}").ConfigureAwait(false);
+ var targetMessage = guildPlayer.GetLocalizedMessage("Chat_Move_PlayerMoved", "You have been moved by the game master.");
+ await this.ShowMessageToAsync(guildPlayer, targetMessage).ConfigureAwait(false);
+ var senderMessage = gameMaster.GetLocalizedMessage(
+ "Chat_Move_TargetMoved",
+ "[{0}] {1} has been moved to {2} at {3}, {4}",
+ this.Key,
+ guildPlayer.Name,
+ exitGate!.Map!.Name,
+ guildPlayer.Position.X,
+ guildPlayer.Position.Y);
+ await this.ShowMessageToAsync(gameMaster, senderMessage).ConfigureAwait(false);
}
}).ConfigureAwait(false);
}
diff --git a/src/GameLogic/PlugIns/ChatCommands/HelpCommand.cs b/src/GameLogic/PlugIns/ChatCommands/HelpCommand.cs
index faabe65d8..0fb080dec 100644
--- a/src/GameLogic/PlugIns/ChatCommands/HelpCommand.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/HelpCommand.cs
@@ -11,8 +11,8 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// The help command which shows the usage of a command.
///
[Guid("EFE9399A-9A14-4B94-BBC1-20718584C4C2")]
-[PlugIn("Help command", "Handles the /help chat command. Shows information about the requested command.")]
-[ChatCommandHelp(Command, "Shows information about the requested command.", typeof(Arguments))]
+[PlugIn("Help command", "Maneja el comando /help . Muestra información del comando solicitado.")]
+[ChatCommandHelp(Command, "Muestra información del comando solicitado.", typeof(Arguments))]
public class HelpCommand : IChatCommandPlugIn
{
private const string Command = "/help";
@@ -34,7 +34,8 @@ public async ValueTask HandleCommandAsync(Player player, string command)
.FirstOrDefault(x => x.Command.Equals("/" + commandName, StringComparison.InvariantCultureIgnoreCase));
if (commandPluginAttribute is null)
{
- await player.ShowMessageAsync($"The command '{commandName}' does not exists.").ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Chat_Help_CommandNotFound", "Command '{0}' does not exist.", commandName);
+ await player.ShowMessageAsync(message).ConfigureAwait(false);
return;
}
@@ -50,4 +51,4 @@ private class Arguments
{
public string? CommandName { get; set; }
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlugIns/ChatCommands/ItemStackChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/ItemStackChatCommandPlugIn.cs
new file mode 100644
index 000000000..c28e810f4
--- /dev/null
+++ b/src/GameLogic/PlugIns/ChatCommands/ItemStackChatCommandPlugIn.cs
@@ -0,0 +1,99 @@
+// Copyright (c) MUnique. Licensed under the MIT license.
+
+namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands.Arguments;
+using MUnique.OpenMU.PlugIns;
+using MUnique.OpenMU.GameLogic.Views.Inventory;
+
+///
+/// Creates a stack (or multiple pieces) of an item.
+/// Usage: /itemstack <group> <number> <count> <lvl?>.
+/// If the item is stackable, it creates one item and sets Durability to min(count, Definition.Durability).
+/// Otherwise, it creates 'count' separate items (subject to inventory space).
+///
+[Guid("0D0B0F6E-0C55-4A8B-A2C0-8C9B67A8D3F2")]
+[PlugIn("Item stack chat command", "Handles the chat command '/itemstack '. Creates a stack or multiple pieces.")]
+[ChatCommandHelp(Command, "Creates a stack or multiple pieces of an item.", typeof(ItemStackChatCommandArgs), CharacterStatus.GameMaster)]
+public sealed class ItemStackChatCommandPlugIn : ChatCommandPlugInBase
+{
+ private const string Command = "/itemstack";
+
+ public override string Key => Command;
+
+ public override CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
+
+ protected override async ValueTask DoHandleCommandAsync(Player gameMaster, ItemStackChatCommandArgs args)
+ {
+ if (args.Count <= 0)
+ {
+ var message = gameMaster.GetLocalizedMessage("Chat_ItemStack_CountPositive", "Count must be greater than zero.");
+ await this.ShowMessageToAsync(gameMaster, message).ConfigureAwait(false);
+ return;
+ }
+
+ var def = gameMaster.GameContext.Configuration.Items
+ .FirstOrDefault(i => i.Group == args.Group && i.Number == args.Number);
+
+ if (def is null)
+ {
+ var message = gameMaster.GetLocalizedMessage("Chat_ItemStack_ItemNotFound", "Item {0} {1} not found.", args.Group, args.Number);
+ await this.ShowMessageToAsync(gameMaster, message).ConfigureAwait(false);
+ return;
+ }
+
+ var isStackable = def.ItemSlot is null && def.Durability > 1;
+ var created = 0;
+
+ if (isStackable)
+ {
+ var item = gameMaster.PersistenceContext.CreateNew();
+ item.Definition = def;
+ item.Level = args.Level;
+ item.Durability = Math.Min(args.Count, (int)def.Durability);
+
+ var slot = gameMaster.Inventory!.CheckInvSpace(item);
+ if (slot is not null && await gameMaster.Inventory.AddItemAsync(slot.Value, item).ConfigureAwait(false))
+ {
+ created = (int)item.Durability;
+ var finalItem = gameMaster.Inventory.GetItem(slot.Value) ?? item;
+ await gameMaster.InvokeViewPlugInAsync(p => p.ItemAppearAsync(finalItem)).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ for (int i = 0; i < args.Count; i++)
+ {
+ var item = gameMaster.PersistenceContext.CreateNew();
+ item.Definition = def;
+ item.Level = args.Level;
+ item.Durability = def.Durability;
+ var slot = gameMaster.Inventory!.CheckInvSpace(item);
+ if (slot is null || !await gameMaster.Inventory.AddItemAsync(slot.Value, item).ConfigureAwait(false))
+ {
+ break;
+ }
+ created++;
+ var finalItem = gameMaster.Inventory.GetItem(slot.Value) ?? item;
+ await gameMaster.InvokeViewPlugInAsync(p => p.ItemAppearAsync(finalItem)).ConfigureAwait(false);
+ }
+ }
+
+ if (created > 0)
+ {
+ var success = gameMaster.GetLocalizedMessage(
+ "Chat_ItemStack_Success",
+ "[{0}] Created {1}x {2}",
+ this.Key,
+ created,
+ def.Name ?? string.Empty);
+ await this.ShowMessageToAsync(gameMaster, success).ConfigureAwait(false);
+ }
+ else
+ {
+ var failure = gameMaster.GetLocalizedMessage("Chat_ItemStack_NoSpace", "[{0}] No space to create items", this.Key);
+ await this.ShowMessageToAsync(gameMaster, failure).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/src/GameLogic/PlugIns/ChatCommands/ListCommand.cs b/src/GameLogic/PlugIns/ChatCommands/ListCommand.cs
index 9de7b24c7..93cf2a5ca 100644
--- a/src/GameLogic/PlugIns/ChatCommands/ListCommand.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/ListCommand.cs
@@ -11,8 +11,8 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A command which lists all available chat commands with their usage.
///
[Guid("a5b0a3e5-bb2a-4287-821a-cd97714fe209")]
-[PlugIn("List command", "Lists all the commands.")]
-[ChatCommandHelp(Command, "Lists all the commands.", null)]
+[PlugIn("List command", "Lista todos los comandos.")]
+[ChatCommandHelp(Command, "Lista todos los comandos.", null)]
public class ListCommand : IChatCommandPlugIn
{
private const string Command = "/list";
@@ -33,4 +33,4 @@ public async ValueTask HandleCommandAsync(Player player, string command)
await player.ShowMessageAsync(commandUsage).ConfigureAwait(false);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlugIns/ChatCommands/MoveChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/MoveChatCommandPlugIn.cs
index 25543a530..829850d8f 100644
--- a/src/GameLogic/PlugIns/ChatCommands/MoveChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/MoveChatCommandPlugIn.cs
@@ -39,8 +39,17 @@ protected override async ValueTask DoHandleCommandAsync(Player sender, MoveChatC
if (!targetPlayer.Name.Equals(sender.Name))
{
- await this.ShowMessageToAsync(targetPlayer, "You have been moved by the game master.").ConfigureAwait(false);
- await this.ShowMessageToAsync(sender, $"[{this.Key}] {targetPlayer.Name} has been moved to {exitGate!.Map!.Name} at {targetPlayer.Position.X}, {targetPlayer.Position.Y}").ConfigureAwait(false);
+ var targetMessage = targetPlayer.GetLocalizedMessage("Chat_Move_PlayerMoved", "You have been moved by the game master.");
+ await this.ShowMessageToAsync(targetPlayer, targetMessage).ConfigureAwait(false);
+ var senderMessage = sender.GetLocalizedMessage(
+ "Chat_Move_TargetMoved",
+ "[{0}] {1} has been moved to {2} at {3}, {4}",
+ this.Key,
+ targetPlayer.Name,
+ exitGate!.Map!.Name,
+ targetPlayer.Position.X,
+ targetPlayer.Position.Y);
+ await this.ShowMessageToAsync(sender, senderMessage).ConfigureAwait(false);
}
}
else
diff --git a/src/GameLogic/PlugIns/ChatCommands/NpcChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/NpcChatCommandPlugIn.cs
index 8c3f11d47..f523fe98b 100644
--- a/src/GameLogic/PlugIns/ChatCommands/NpcChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/NpcChatCommandPlugIn.cs
@@ -20,14 +20,14 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin which opens NPC windows.
///
[Guid("D8AC2F15-AB30-4432-A042-A41ACA1B274D")]
-[PlugIn("NPC open merchant chat command", "Opens the merchant NPC store.")]
-[ChatCommandHelp(Command, "Opens the NPC store.", null)]
+[PlugIn("NPC open merchant chat command", "Abre la tienda del NPC comerciante.")]
+[ChatCommandHelp(Command, "Abre la tienda del NPC.", null)]
public class NpcChatCommandPlugIn : ChatCommandPlugInBase, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisabledByDefault
{
private const string Command = "/npc";
private const CharacterStatus MinimumStatus = CharacterStatus.Normal;
- private const string InvalidNpcIdMessage = "Invalid NPC ID \"{0}\". Please provide a valid merchant NPC ID.";
- private const string InvalidMerchantMessage = "Not a valid merchant NPC.";
+ private const string InvalidNpcIdMessage = "ID de NPC \"{0}\" inválido. Por favor proporciona un ID de comerciante válido.";
+ private const string InvalidMerchantMessage = "NPC no es un comerciante válido.";
private readonly TalkNpcAction _talkNpcAction = new();
diff --git a/src/GameLogic/PlugIns/ChatCommands/OpenWarehouseChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/OpenWarehouseChatCommandPlugIn.cs
index 312f410e4..5d44fac6c 100644
--- a/src/GameLogic/PlugIns/ChatCommands/OpenWarehouseChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/OpenWarehouseChatCommandPlugIn.cs
@@ -19,13 +19,13 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin which opens the warehouse NPC window.
///
[Guid("62027B6B-D8E7-4DDB-A16B-7070D1BC4A56")]
-[PlugIn("Open Warehouse chat command", "Opens the warehouse.")]
-[ChatCommandHelp(Command, "Opens the warehouse.", null)]
+[PlugIn("Open Warehouse chat command", "Abre el baúl.")]
+[ChatCommandHelp(Command, "Abre el baúl.", null)]
public class OpenWarehouseChatCommandPlugIn : ChatCommandPlugInBase, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisabledByDefault
{
private const string Command = "/openware";
private const CharacterStatus MinimumStatus = CharacterStatus.Normal;
- private const string NoWarehouseNpcMessage = "No warehouse NPC found";
+ private const string NoWarehouseNpcMessage = "No se encontró NPC de baúl";
private readonly TalkNpcAction _talkNpcAction = new();
@@ -95,6 +95,6 @@ public class OpenWarehouseChatCommandConfiguration
/// Gets or sets the message to show when the player does not have the required VIP level for this command (excluding GM).
///
[Display(Name = "Insufficient VIP Level Message", Description = @"The message to show when the player does not have the required VIP level for this command (excluding GM).")]
- public string InsufficientVipLevelMessage { get; set; } = "Insufficient VIP level to use this command";
+ public string InsufficientVipLevelMessage { get; set; } = "Nivel VIP insuficiente para usar este comando";
}
}
\ No newline at end of file
diff --git a/src/GameLogic/PlugIns/ChatCommands/SetLevelChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/SetLevelChatCommandPlugIn.cs
index ccfb75fd4..0b6fbbda4 100644
--- a/src/GameLogic/PlugIns/ChatCommands/SetLevelChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/SetLevelChatCommandPlugIn.cs
@@ -4,7 +4,6 @@
namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
-using System.Globalization;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Views.Character;
@@ -15,16 +14,12 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin which sets a character's level.
///
[Guid("4BE779C9-E6B6-47F2-BC23-2E71D82A6C1D")]
-[PlugIn("Set level command", "Sets level of a player. Usage: /setlevel (level) (optional:character)")]
-[ChatCommandHelp(Command, "Sets level of a player. Usage: /setlevel (level) (optional:character)", null)]
+[PlugIn("Set level command", "Establece el nivel de un jugador. Uso: /setlevel (nivel) (opcional:personaje)")]
+[ChatCommandHelp(Command, "Establece el nivel de un jugador. Uso: /setlevel (nivel) (opcional:personaje)", null)]
public class SetLevelChatCommandPlugIn : ChatCommandPlugInBase, IDisabledByDefault
{
private const string Command = "/setlevel";
private const CharacterStatus MinimumStatus = CharacterStatus.GameMaster;
- private const string CharacterNotFoundMessage = "Character '{0}' not found.";
- private const string InvalidLevelMessage = "Invalid level - must be between 1 and {0}.";
- private const string LevelSetMessage = "Level set to {0}.";
-
///
public override string Key => Command;
@@ -41,7 +36,8 @@ protected override async ValueTask DoHandleCommandAsync(Player player, Arguments
if (targetPlayer?.SelectedCharacter is null ||
!targetPlayer.SelectedCharacter.Name.Equals(characterName, StringComparison.OrdinalIgnoreCase))
{
- await this.ShowMessageToAsync(player, string.Format(CultureInfo.InvariantCulture, CharacterNotFoundMessage, characterName)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Chat_SetLevel_CharacterNotFound", "Character '{0}' not found.", characterName);
+ await this.ShowMessageToAsync(player, message).ConfigureAwait(false);
return;
}
}
@@ -53,14 +49,16 @@ protected override async ValueTask DoHandleCommandAsync(Player player, Arguments
if (arguments is null || arguments.Level < 1 || arguments.Level > targetPlayer.GameContext.Configuration.MaximumLevel)
{
- await this.ShowMessageToAsync(player, string.Format(CultureInfo.InvariantCulture, InvalidLevelMessage, targetPlayer.GameContext.Configuration.MaximumLevel)).ConfigureAwait(false);
+ var message = player.GetLocalizedMessage("Chat_SetLevel_InvalidLevel", "Invalid level - must be between 1 and {0}.", targetPlayer.GameContext.Configuration.MaximumLevel);
+ await this.ShowMessageToAsync(player, message).ConfigureAwait(false);
return;
}
targetPlayer.Attributes![Stats.Level] = checked(arguments.Level);
await targetPlayer.InvokeViewPlugInAsync(p => p.UpdateLevelAsync()).ConfigureAwait(false);
await targetPlayer.ForEachWorldObserverAsync(p => p.ShowEffectAsync(targetPlayer, IShowEffectPlugIn.EffectType.LevelUp), true).ConfigureAwait(false);
- await this.ShowMessageToAsync(player, string.Format(CultureInfo.InvariantCulture, LevelSetMessage, arguments.Level)).ConfigureAwait(false);
+ var confirmation = player.GetLocalizedMessage("Chat_SetLevel_Success", "Level set to {0}.", arguments.Level);
+ await this.ShowMessageToAsync(player, confirmation).ConfigureAwait(false);
}
///
diff --git a/src/GameLogic/PlugIns/ChatCommands/SetLevelUpPointsChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/SetLevelUpPointsChatCommandPlugIn.cs
index 2777c52e3..a362833f1 100644
--- a/src/GameLogic/PlugIns/ChatCommands/SetLevelUpPointsChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/SetLevelUpPointsChatCommandPlugIn.cs
@@ -14,15 +14,15 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin which sets a character's level-up points.
///
[Guid("50EF670A-DF7A-4FEE-8E42-7C7A18A68941")]
-[PlugIn("Set level up points command", "Sets level up points of a player. Usage: /setleveluppoints (points) (optional:character)")]
-[ChatCommandHelp(Command, "Sets level up points of a player. Usage: /setleveluppoints (points) (optional:character)", null)]
+[PlugIn("Set level up points command", "Establece los puntos de nivel de un jugador. Uso: /setleveluppoints (puntos) (opcional:personaje)")]
+[ChatCommandHelp(Command, "Establece los puntos de nivel de un jugador. Uso: /setleveluppoints (puntos) (opcional:personaje)", null)]
public class SetLevelUpPointsChatCommandPlugIn : ChatCommandPlugInBase, IDisabledByDefault
{
private const string Command = "/setleveluppoints";
private const CharacterStatus MinimumStatus = CharacterStatus.GameMaster;
- private const string CharacterNotFoundMessage = "Character '{0}' not found.";
- private const string InvalidLevelUpPointsMessage = "Invalid level-up points - must be bigger or equal to 0.";
- private const string LevelUpPointsSetMessage = "Level-up points set to {0}.";
+ private const string CharacterNotFoundMessage = "Personaje '{0}' no encontrado.";
+ private const string InvalidLevelUpPointsMessage = "Puntos de nivel inválidos - deben ser mayores o iguales a 0.";
+ private const string LevelUpPointsSetMessage = "Puntos de nivel establecidos en {0}.";
///
public override string Key => Command;
diff --git a/src/GameLogic/PlugIns/ChatCommands/SetMasterLevelChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/SetMasterLevelChatCommandPlugIn.cs
index b02036e91..eea3fa732 100644
--- a/src/GameLogic/PlugIns/ChatCommands/SetMasterLevelChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/SetMasterLevelChatCommandPlugIn.cs
@@ -15,15 +15,15 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin which sets a character's master level.
///
[Guid("E401CA16-7827-495B-9DD0-EABDFF39901E")]
-[PlugIn("Set master level command", "Sets master level of a player. Usage: /setmasterlevel (level) (optional:character)")]
-[ChatCommandHelp(Command, "Sets master level of a player. Usage: /setmasterlevel (level) (optional:character)", null)]
+[PlugIn("Set master level command", "Establece el nivel maestro de un jugador. Uso: /setmasterlevel (nivel) (opcional:personaje)")]
+[ChatCommandHelp(Command, "Establece el nivel maestro de un jugador. Uso: /setmasterlevel (nivel) (opcional:personaje)", null)]
public class SetMasterLevelChatCommandPlugIn : ChatCommandPlugInBase, IDisabledByDefault
{
private const string Command = "/setmasterlevel";
private const CharacterStatus MinimumStatus = CharacterStatus.GameMaster;
- private const string CharacterNotFoundMessage = "Character '{0}' not found.";
- private const string InvalidLevelMessage = "Invalid level - must be between 1 and {0}.";
- private const string MasterLevelSetMessage = "Master level set to {0}.";
+ private const string CharacterNotFoundMessage = "Personaje '{0}' no encontrado.";
+ private const string InvalidLevelMessage = "Nivel inválido - debe estar entre 1 y {0}.";
+ private const string MasterLevelSetMessage = "Nivel maestro establecido en {0}.";
///
public override string Key => Command;
diff --git a/src/GameLogic/PlugIns/ChatCommands/SetMasterLevelUpPointsChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/SetMasterLevelUpPointsChatCommandPlugIn.cs
index e3ec23be6..eb753943e 100644
--- a/src/GameLogic/PlugIns/ChatCommands/SetMasterLevelUpPointsChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/SetMasterLevelUpPointsChatCommandPlugIn.cs
@@ -14,15 +14,15 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin which sets a character's master level-up points.
///
[Guid("69AC0B9E-1063-448E-ABD6-C5837A1E8A4B")]
-[PlugIn("Set master level up points command", "Sets master level up points of a player. Usage: /setmasterleveluppoints (points) (optional:character)")]
-[ChatCommandHelp(Command, "Sets master level up points of a player. Usage: /setmasterleveluppoints (points) (optional:character)", null)]
+[PlugIn("Set master level up points command", "Establece los puntos de nivel maestro de un jugador. Uso: /setmasterleveluppoints (puntos) (opcional:personaje)")]
+[ChatCommandHelp(Command, "Establece los puntos de nivel maestro de un jugador. Uso: /setmasterleveluppoints (puntos) (opcional:personaje)", null)]
public class SetMasterLevelUpPointsChatCommandPlugIn : ChatCommandPlugInBase, IDisabledByDefault
{
private const string Command = "/setmasterleveluppoints";
private const CharacterStatus MinimumStatus = CharacterStatus.GameMaster;
- private const string CharacterNotFoundMessage = "Character '{0}' not found.";
- private const string InvalidMasterLevelUpPointsMessage = "Invalid master level-up points - must be bigger or equal to 0.";
- private const string MasterLevelUpPointsSetMessage = "Master level-up points set to {0}.";
+ private const string CharacterNotFoundMessage = "Personaje '{0}' no encontrado.";
+ private const string InvalidMasterLevelUpPointsMessage = "Puntos de nivel maestro inválidos - deben ser mayores o iguales a 0.";
+ private const string MasterLevelUpPointsSetMessage = "Puntos de nivel maestro establecidos en {0}.";
///
public override string Key => Command;
diff --git a/src/GameLogic/PlugIns/ChatCommands/SetMoneyChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/SetMoneyChatCommandPlugIn.cs
index 6f87303c0..5e0ae5bb0 100644
--- a/src/GameLogic/PlugIns/ChatCommands/SetMoneyChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/SetMoneyChatCommandPlugIn.cs
@@ -14,15 +14,15 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin which sets a character's money.
///
[Guid("00AA4F0E-911D-49FE-8D88-114C7496D383")]
-[PlugIn("Set money command", "Sets money of a player. Usage: /setmoney (amount) (optional:character)")]
-[ChatCommandHelp(Command, "Sets money of a player. Usage: /setmoney (amount) (optional:character)", null)]
+[PlugIn("Set money command", "Establece el Zen de un jugador. Uso: /setmoney (cantidad) (opcional:personaje)")]
+[ChatCommandHelp(Command, "Establece el Zen de un jugador. Uso: /setmoney (cantidad) (opcional:personaje)", null)]
public class SetMoneyChatCommandPlugIn : ChatCommandPlugInBase, IDisabledByDefault
{
private const string Command = "/setmoney";
private const CharacterStatus MinimumStatus = CharacterStatus.GameMaster;
- private const string CharacterNotFoundMessage = "Character '{0}' not found.";
- private const string InvalidAmountMessage = "Invalid amount - must be between 0 and {0}.";
- private const string MoneySetMessage = "Money set to {0}.";
+ private const string CharacterNotFoundMessage = "Personaje '{0}' no encontrado.";
+ private const string InvalidAmountMessage = "Cantidad inválida - debe estar entre 0 y {0}.";
+ private const string MoneySetMessage = "Zen establecido en {0}.";
///
public override string Key => Command;
diff --git a/src/GameLogic/PlugIns/ChatCommands/SetResetsChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/SetResetsChatCommandPlugIn.cs
index 41d6e0a20..5b5541ea3 100644
--- a/src/GameLogic/PlugIns/ChatCommands/SetResetsChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/SetResetsChatCommandPlugIn.cs
@@ -15,17 +15,17 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin which sets a character's resets.
///
[Guid("47A8644C-B6C5-439E-BAB0-C1A7AE72691C")]
-[PlugIn("Set resets command", "Sets resets of a player. Usage: /setresets (resets) (optional:character)")]
-[ChatCommandHelp(Command, "Sets resets of a player. Usage: /setresets (resets) (optional:character)", null)]
+[PlugIn("Set resets command", "Establece los resets de un jugador. Uso: /setresets (resets) (opcional:personaje)")]
+[ChatCommandHelp(Command, "Establece los resets de un jugador. Uso: /setresets (resets) (opcional:personaje)", null)]
public class SetResetsChatCommandPlugIn : ChatCommandPlugInBase, IDisabledByDefault
{
private const string Command = "/setresets";
private const CharacterStatus MinimumStatus = CharacterStatus.GameMaster;
- private const string ResetPluginDisabledMessage = "The reset system is not enabled on this server.";
- private const string CharacterNotFoundMessage = "Character '{0}' not found.";
- private const string InvalidResetsWithLimitMessage = "Invalid resets - must be between 0 and {0}.";
- private const string InvalidResetsNoLimitMessage = "Invalid resets - must be bigger than 0.";
- private const string ResetsSetMessage = "Resets set to {0}.";
+ private const string ResetPluginDisabledMessage = "El sistema de resets no está habilitado en este servidor.";
+ private const string CharacterNotFoundMessage = "Personaje '{0}' no encontrado.";
+ private const string InvalidResetsWithLimitMessage = "Resets inválidos - deben estar entre 0 y {0}.";
+ private const string InvalidResetsNoLimitMessage = "Resets inválidos - deben ser mayores que 0.";
+ private const string ResetsSetMessage = "Resets establecidos en {0}.";
///
public override string Key => Command;
diff --git a/src/GameLogic/PlugIns/ChatCommands/SetStatChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/SetStatChatCommandPlugIn.cs
index 05959bb1c..f424a3962 100644
--- a/src/GameLogic/PlugIns/ChatCommands/SetStatChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/SetStatChatCommandPlugIn.cs
@@ -15,17 +15,17 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
/// A chat command plugin which handles the command to add stat points.
///
[Guid("D074E8AB-9D6E-49A4-956F-1F4818188AF1")]
-[PlugIn("Set Stat chat command", "Set stat points. Usage: /set (ene|agi|vit|str|cmd) (amount) (optional:character)")]
-[ChatCommandHelp(Command, "Set stat points. Usage: /set (ene|agi|vit|str|cmd) (amount) (optional:character)", typeof(Arguments), MinimumStatus)]
+[PlugIn("Set Stat chat command", "Establece puntos de atributo. Uso: /set (ene|agi|vit|str|cmd) (cantidad) (opcional:personaje)")]
+[ChatCommandHelp(Command, "Establece puntos de atributo. Uso: /set (ene|agi|vit|str|cmd) (cantidad) (opcional:personaje)", typeof(Arguments), MinimumStatus)]
public class SetStatChatCommandPlugIn : ChatCommandPlugInBase, IDisabledByDefault
{
private const string Command = "/set";
private const CharacterStatus MinimumStatus = CharacterStatus.GameMaster;
- private const string CharacterNotFoundMessage = "Character '{0}' not found.";
- private const string InvalidStatWithLimitMessage = "Invalid {0} - must be between 0 and {1}.";
- private const string InvalidStatNoLimitMessage = "Invalid {0} - must be bigger than 1.";
- private const string StatSetMessage = "{0} set to {1}.";
+ private const string CharacterNotFoundMessage = "Personaje '{0}' no encontrado.";
+ private const string InvalidStatWithLimitMessage = "Valor inválido {0} - debe estar entre 0 y {1}.";
+ private const string InvalidStatNoLimitMessage = "Valor inválido {0} - debe ser mayor que 1.";
+ private const string StatSetMessage = "{0} establecido en {1}.";
///
public override string Key => Command;
@@ -97,12 +97,12 @@ private AttributeDefinition GetAttribute(Character selectedCharacter, string? st
"vit" => Stats.BaseVitality,
"ene" => Stats.BaseEnergy,
"cmd" => Stats.BaseLeadership,
- _ => throw new ArgumentException($"Unknown stat: '{statType}'."),
+ _ => throw new ArgumentException($"Atributo desconocido: '{statType}'."),
};
if (selectedCharacter.Attributes.All(sa => sa.Definition != attribute))
{
- throw new ArgumentException($"The character has no stat attribute '{statType}'.");
+ throw new ArgumentException($"El personaje no tiene el atributo '{statType}'.");
}
return attribute;
@@ -129,4 +129,4 @@ public class Arguments : ArgumentsBase
///
public string? CharacterName { get; set; }
}
-}
\ No newline at end of file
+}
diff --git a/src/GameLogic/PlugIns/ChatCommands/StartWhiteWizardInvasionChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/StartWhiteWizardInvasionChatCommandPlugIn.cs
new file mode 100644
index 000000000..e03b843ab
--- /dev/null
+++ b/src/GameLogic/PlugIns/ChatCommands/StartWhiteWizardInvasionChatCommandPlugIn.cs
@@ -0,0 +1,51 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.GameLogic.PlugIns;
+using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
+using MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// A chat command plugin which handles the startww command.
+/// Starts the (custom) White Wizard invasion at the next possible time.
+///
+[Guid("55C53E9B-7D8B-4B83-9A87-0AC7A6B53E5E")]
+[PlugIn(nameof(StartWhiteWizardInvasionChatCommandPlugIn), "Handles the chat command '/startww'. Starts the White Wizard invasion at the next possible time.")]
+[ChatCommandHelp(Command, "Starts the White Wizard invasion at the next possible time.", CharacterStatus.GameMaster)]
+public class StartWhiteWizardInvasionChatCommandPlugIn : IChatCommandPlugIn
+{
+ private const string Command = "/startww";
+
+ ///
+ public string Key => Command;
+
+ ///
+ public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
+
+ ///
+ public async ValueTask HandleCommandAsync(Player player, string command)
+ {
+ // Get the periodic task plugin point container to enumerate actual instances
+ var pluginPoint = player.GameContext.PlugInManager.GetPlugInPoint();
+ if (pluginPoint is IPlugInContainer container)
+ {
+ var ww = container.ActivePlugIns.FirstOrDefault(p => p is WhiteWizardInvasionPlugIn);
+ if (ww is object)
+ {
+ // ForceStart is defined on PeriodicTaskBasePlugIn; use reflection to avoid generic constraints
+ ww.GetType().GetMethod("ForceStart")?.Invoke(ww, Array.Empty