diff --git a/.gitignore b/.gitignore index b958cfa24e..7ea0db406d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ node_modules ui/core/index.ts ui/*/*/index.html .dirstamp -.vscode/launch.json + # IDE folders .idea .history diff --git a/.vscode/settings.json b/.vscode/settings.json index 7f94fc83f2..ed3ace36f7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -101,5 +101,6 @@ ], "[go]": { "editor.defaultFormatter": "golang.go" - } + }, + "makefile.configureOnOpen": false } diff --git a/README.md b/README.md index 08105061ae..52ba4c46f1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -Welcome to the WoW Cataclysm Classic simulator! If you have questions or are thinking about contributing, [join our discord](https://discord.gg/jJMPr9JWwx 'https://discord.gg/jJMPr9JWwx') to chat! +# WoW Mists of Pandaria Classic Simulator + +Welcome to the WoW Mists of Pandaria Classic simulator! If you have questions or are thinking about contributing, [join our discord](https://discord.gg/jJMPr9JWwx) to chat! The primary goal of this project is to provide a framework that makes it easy to build a DPS sim for any class/spec, with a polished UI and accurate results. Each community will have ownership / responsibility over their portion of the sim, to ensure accuracy and that their community is represented. By having all the individual sims on the same engine, we can also have a combined 'raid sim' for testing raid compositions. @@ -8,7 +10,7 @@ This project is licensed with MIT license. We request that anyone using this sof [Support our devs via Patreon.](https://www.patreon.com/wowsims) -# Downloading Sim +## Downloading Sim Links for latest Sim build: @@ -20,256 +22,9 @@ Then unzip the downloaded file, then open the unzipped file to open the sim in y Alternatively, you can choose from a specific relase on the [Releases](https://github.com/wowsims/mop/releases) page and click the suitable link under "Assets" -# Local Dev Installation - -This project has dependencies on Go >=1.23, protobuf-compiler and the corresponding Go plugins, and node >= 20. - -## Ubuntu - -Do not use apt to install any dependencies, the versions they install are all too old. -Script below will curl latest versions and install them. - -```sh -# Standard Go installation script -curl -O https://dl.google.com/go/go1.23.4.linux-amd64.tar.gz -sudo rm -rf /usr/local/go -sudo tar -C /usr/local -xzf go1.23.4.linux-amd64.tar.gz -echo 'export PATH=$PATH:/usr/local/go/bin' >> $HOME/.bashrc -echo 'export GOPATH=$HOME/go' >> $HOME/.bashrc -echo 'export PATH=$PATH:$GOPATH/bin' >> $HOME/.bashrc -source $HOME/.bashrc - -cd mop - -# Install protobuf compiler and Go plugins -sudo apt update && sudo apt upgrade -sudo apt install protobuf-compiler -go get -u -v google.golang.org/protobuf -go install google.golang.org/protobuf/cmd/protoc-gen-go@latest - -# Install node -curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash -nvm install 20.13.1 - -# Install the npm package dependencies using node -npm install -``` - -## Docker - -Alternatively, install Docker and your workflow will look something like this: - -```sh -git clone https://github.com/wowsims/mop.git -cd mop - -# Build the docker image and install npm dependencies (only need to run these once). -docker build --tag wowsims-mop . -docker run --rm -v $(pwd):/mop wowsims-mop npm install - -# Now you can run the commands as shown in the Commands sections, preceding everything with, "docker run --rm -it -p 8080:8080 -v $(pwd):/mop wowsims-mop". -# For convenience, set this as an environment variable: -MOP_CMD="docker run --rm -it -p 8080:8080 -v $(pwd):/mop wowsims-mop" - -#For the watch commands assign this environment variable: -MOP_WATCH_CMD="docker run --rm -it -p 8080:8080 -p 3333:3333 -p 5173:5173 -e WATCH=1 -v $(pwd):/mop wowsims-mop" - -# ... do some coding on the sim ... - -# Run tests -$(echo $MOP_CMD) make test - -# ... do some coding on the UI ... - -# Host a local site -$(echo $MOP_CMD) make host -``` - -## Windows - -If you want to develop on Windows, we recommend setting up a Ubuntu virtual machine (VM) or running Docker using [this guide](https://docs.docker.com/desktop/windows/wsl/ 'https://docs.docker.com/desktop/windows/wsl/') and then following the Ubuntu or Docker instructions, respectively. - -If you prefer working natively: - -- Install [Go](https://go.dev/dl/s), [NVM Windows](https://github.com/coreybutler/nvm-windows), and [make](https://gnuwin32.sourceforge.net/packages/make.htm) (you can also install it through Chocolate). -- Install and use Node 20+ from NVM, for example `nvm install 20 && nvm use 20` -- Setup GO workspace following [this guide](https://www.freecodecamp.org/news/setting-up-go-programming-language-on-windows-f02c8c14e2f/) -- Download GO dependencies [protobuf](https://github.com/protocolbuffers/protobuf/releases), [gopls](https://github.com/golang/tools/releases), [air-verse](https://github.com/air-verse/air/releases), [protobuf-go](https://github.com/protocolbuffers/protobuf-go/releases), and [staticcheck](https://github.com/dominikh/go-tools/releases). Unzip them into your GO workspace directory. - -With all the dependencies setup, you should be able to run the `make` commands and compile the project. - -If you prefer working natively: - -- Install [Go](https://go.dev/dl/s), [NVM Windows](https://github.com/coreybutler/nvm-windows), and [make](https://gnuwin32.sourceforge.net/packages/make.htm) (you can also install it through Chocolate). -- Install and use Node 20+ from NVM, for example `nvm install 20 && nvm use 20` -- Setup GO workspace following [this guide](https://www.freecodecamp.org/news/setting-up-go-programming-language-on-windows-f02c8c14e2f/) -- Download GO dependencies [protobuf](https://github.com/protocolbuffers/protobuf/releases), [gopls](https://github.com/golang/tools/releases), [air-verse](https://github.com/air-verse/air/releases), [protobuf-go](https://github.com/protocolbuffers/protobuf-go/releases), and [staticcheck](https://github.com/dominikh/go-tools/releases). Unzip them into your GO workspace directory. - -With all the dependencies setup, you should be able to run the `make` commands and compile the project. - -## Mac OS - -- Docker is available in OS X as well, so in theory similar instructions should work for the Docker method -- You can also use the Ubuntu setup instructions as above to run natively, with a few modifications: - - You may need a different Go installer if `go1.18.3.linux-amd64.tar.gz` is not compatible with your system's architecture; you can do the Go install manually from `https://go.dev/doc/install`. - - OS X uses Homebrew instead of apt, so in order to install protobuf-compiler you’ll instead need to run `brew install protobuf-c` (note the package name is also a little different than in apt). You might need to first update or upgrade brew. - - The provided install script for Node will not included a precompiled binary for OS X, but it’s smart enough to compile one. Be ready for your CPU to melt on running `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash`. - -# Commands - -We use a makefile for our build system. These commands will usually be all you need while developing for this project: - -```sh -# Installs a pre-commit git hook so that your go code is automatically formatted (if you don't use an IDE that supports that). If you want to manually format go code you can run make fmt. -# Also installs `air` to reload the dev servers automatically -make setup - -# Run all the tests. Currently only the backend sim has tests. -make test - -# Update the expected test results. This will need to be run after adding/removing any tests, and also if test results change due to code changes. -make update-tests - -# Host a local version of the UI at http://localhost:8080. Visit it by pointing a browser to -# http://localhost:8080/mop/YOUR_SPEC_HERE, where YOUR_SPEC_HERE is the directory under ui/ with your custom code. -# Recompiles the entire client before launching using `make dist/mop` -make host - -# With file-watching so the server auto-restarts and recompiles on Go or TS changes: -WATCH=1 make host - -# Delete all generated files (.pb.go and .ts proto files, and dist/) -make clean - -# Recompiles the ts only for the given spec (e.g. make host_elemental_shaman) -make host_$spec - -# Recompiles the `wowsimmop` server binary and runs it, hosting /dist directory at http://localhost:3333/mop. -# This is the fastest way to iterate on core go simulator code so you don't have to wait for client rebuilds. -# To rebuild client for a spec just do 'make $spec' and refresh browser. -make rundevserver - -# With file-watching so the server auto-restarts and recompiles on Go or TS changes: -WATCH=1 make rundevserver - - -# The same as rundevserver, recompiles `wowsimmop` binary and runs it on port 3333. Instead of serving content from the dist folder, -# this command also runs `vite serve` to start the Vite dev server on port 5173 (or similar) and automatically reloads the page on .ts changes in less than a second. -# This allows for more rapid development, with sub second reloads on TS changes. This combines the benefits of `WATCH=1 make rundevserver` and `WATCH=1 make host` -# to create something that allows you to work in any part of the code with ease and speed. -# This might get rolled into `WATCH=1 make rundevserver` at some point. -WATCH=1 make devmode - -# This is just the same as rundevserver currently -make devmode - -# This command recompiles the workers in the /ui/worker folder for easier debugging/development -# Can be used with or without WATCH command -make webworkers - -# With file watch enabled -WATCH=1 make webworkers - -# Creates the 'wowsimmop' binary that can host the UI and run simulations natively (instead of with wasm). -# Builds the UI and the compiles it into the binary so that you can host the sim as a server instead of wasm on the client. -# It does this by first doing make dist/mop and then copying all those files to binary_dist/mop and loading all the files in that directory into its binary on compile. -make wowsimmop - -# Using the --usefs flag will instead of hosting the client built into the binary, it will host whatever code is found in the /dist directory. -# Use --wasm to host the client with the wasm simulator. -# The server also disables all caching so that refreshes should pickup any changed files in dist/. The client will still call to the server to run simulations so you can iterate more quickly on client changes. -# make dist/mop && ./wowsimmop --usefs would rebuild the whole client and host it. (you would have had to run `make devserver` to build the wowsimmop binary first.) -./wowsimmop --usefs - -# Generate code for the sim database (db.json). Only necessary if you changed the items generator. -# Useful only if you're actively working on the generator and have already run make db locally at least once. -make simdb - -# Generate data from WoW client files -# Requires dotnet 9 to run -# Uses tools/database/generator-settings.json for settings -# Also runs make simdb -# This is what you will use most of the time for generation -make db - -# Same as make db but from the ptr client -# Uses tools/database/ptr-generator-settings.json for settings -make ptrdb -``` - -## (Optional) Installing Dotnet 9 - Required if generating client data - -```sh -curl -L https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh -chmod +x ./dotnet-install.sh -./dotnet-install.sh --channel 9.0 -echo 'export PATH=$PATH:$HOME/.dotnet' >> ~/.bashrc -source ~/.bashrc -``` - -# Adding a Sim - -So you want to make a new sim for your class/spec! The basic steps are as follows: - -- [Create the proto interface between sim and UI.](#create-the-proto-interface-between-sim-and-ui) -- [Implement the UI.](#implement-the-ui) -- [Implement the sim.](#implement-the-sim) -- [Launch the site.](#launch-the-site) - -## Create the proto interface between Sim and UI - -This project uses [Google Protocol Buffers](https://developers.google.com/protocol-buffers/docs/gotutorial 'https://developers.google.com/protocol-buffers/docs/gotutorial') to pass data between the sim and the UI. TLDR; Describe data structures in .proto files, and the tool can generate code in any programming language. It lets us avoid repeating the same code in our Go and Typescript worlds without losing type safety. - -For a new sim, make the following changes: - -- Add a new value to the `Spec` enum in proto/common.proto. **NOTE: The name you give to this enum value is not just a name, it is used in our templating system. This guide will refer to this name as `$SPEC` elsewhere.** -- Add a 'proto/YOUR_CLASS.proto' file if it doesn't already exist and add data messages containing all the class/spec-specific information needed to run your sim. -- Update the `PlayerOptions.spec` field in `proto/api.proto` to include your shiny new message as an option. - -That's it! Now when you run `make` there will be generated .go and .ts code in `sim/core/proto` and `ui/core/proto` respectively. If you aren't familiar with protos, take a quick look at them to see what's happening. - -## Implement the UI - -The UI and sim can be done in either order, but it is generally recommended to build the UI first because it can help with debugging. The UI is very generalized and it doesn't take much work to build an entire sim UI using our templating system. To use it: - -- Modify `ui/core/proto_utils/utils.ts` to include boilerplate for your `$SPEC` name if it isn't already there. -- Create a directory `ui/$SPEC`. So if your Spec enum value was named, `elemental_shaman`, create a directory, `ui/elemental_shaman`. -- Copy+paste from another spec's UI code. -- Modify all the files for your spec; most of the settings are fairly obvious, if you need anything complex just ask and we can help! -- Finally, add a rule to the `makefile` for the new sim site. Just copy from the other site rules already there and change the `$SPEC` names. - -No .html is needed, it will be generated based on `ui/index_template.html` and the `$SPEC` name. - -When you're ready to try out the site, run `make host` and navigate to `http://localhost:8080/mop/$SPEC`. - -## Implement the Sim - -This step is where most of the magic happens. A few highlights to start understanding the sim code: - -- `sim/wasm/main.go` This file is the actual main function, for the [.wasm binary](https://webassembly.org/ 'https://webassembly.org/') used by the UI. You shouldn't ever need to touch this, but just know its here. -- `sim/core/api.go` This is where the action starts. This file implements the request/response messages defined in `proto/api.proto`. -- `sim/core/sim.go` Orchestrates everything. Main event loop is in `Simulation.RunOnce`. -- `sim/core/agent.go` An Agent can be thought of as the 'Player', i.e. the person controlling the game. This is the interface you'll be implementing. -- `sim/core/character.go` A Character holds all the stats/cooldowns/gear/etc common to any WoW character. Each Agent has a Character that it controls. - -Read through the core code and some examples from other classes/specs to get a feel for what's needed. Hopefully `sim/core` already includes what you need, but most classes have at least 1 unique mechanic so you may need to touch `core` as well. - -Finally, add your new sim to `RegisterAll()` in `sim/register_all.go`. - -Don't forget to write unit tests! Again, look at existing tests for examples. Run them with `make test` when you're ready. - -# Launch the site - -When everything is ready for release, modify `ui/core/launched_sims.ts` and `ui/index.html` to include the new spec value. This will add the sim to the dropdown menu so anyone can find it from the existing sims. This will also remove the UI warning that the sim is under development. Now tell everyone about your new sim! - -# Add your spec to the raid sim - -Don't touch the raid sim until the individual sim is ready for launch; anything in the raid sim is publicly accessible. To add your new spec to the raid sim, do the following: - -- Add a reference to the individual sim in `ui/raid/tsconfig.json`. DO NOT FORGET THIS STEP or Typescipt will silently do very bad things. -- Import the individual sim's css file from `ui/raid/index.scss`. -- Update `ui/raid/presets.ts` to include a constructor factory in the `specSimFactories` variable and add configurations for new Players in the `playerPresets` variable. - -# Deployment +## Documentation -Thanks to the workflow defined in `.github/workflows/deploy.yml`, pushes to `master` automatically build and deploy a new site so there's nothing to do here. Sit back and appreciate your new sim! +- [Installation Guide](docs/installation.md) +- [Development Commands](docs/commands.md) +- [Adding a New Sim](docs/adding_sim.md) +- [Internationalization](docs/i18n_guide.md) diff --git a/assets/locales/en.json b/assets/locales/en.json new file mode 100644 index 0000000000..ef14578553 --- /dev/null +++ b/assets/locales/en.json @@ -0,0 +1,207 @@ +{ + "landing": { + "navigation": { + "home": "Home", + "simulations": "Simulations", + "about": "About", + "toggle": "Toggle navigation" + }, + "simulations": { + "full_raid": "Full Raid Sim" + }, + "home": { + "title": "WoWSims - Mists of Pandaria", + "description": "A powerful simulation tool for World of Warcraft: Mists of Pandaria", + "welcomeDescription": "Welcome to WoWSims - Mists of Pandaria! This is a community-driven project to provide class and raid simulations for World of Warcraft® Mists of Pandaria Classic together with the leading theorycrafters and class representatives." + }, + "header": { + "wowsims": "WoWSims", + "expansion": "Mists of Pandaria", + "supportDevs": "Support our devs" + } + }, + "common": { + "phases": { + "1": "Phase 1 (T14)", + "2": "Phase 2 (T15)", + "3": "Phase 3 (T16)" + }, + "status": { + "unlaunched": "Not Yet Supported", + "alpha": "Alpha", + "beta": "Beta", + "launched": "Launched" + }, + "classes": { + "death_knight": "Death Knight", + "druid": "Druid", + "hunter": "Hunter", + "mage": "Mage", + "monk": "Monk", + "paladin": "Paladin", + "priest": "Priest", + "rogue": "Rogue", + "shaman": "Shaman", + "warlock": "Warlock", + "warrior": "Warrior" + }, + "specs": { + "death_knight": { + "blood": "Blood", + "frost": "Frost", + "unholy": "Unholy" + }, + "druid": { + "balance": "Balance", + "feral": "Feral", + "guardian": "Guardian", + "restoration": "Restoration" + }, + "hunter": { + "beast_mastery": "Beast Mastery", + "marksmanship": "Marksmanship", + "survival": "Survival" + }, + "mage": { + "arcane": "Arcane", + "fire": "Fire", + "frost": "Frost" + }, + "monk": { + "brewmaster": "Brewmaster", + "mistweaver": "Mistweaver", + "windwalker": "Windwalker" + }, + "paladin": { + "holy": "Holy", + "protection": "Protection", + "retribution": "Retribution" + }, + "priest": { + "discipline": "Discipline", + "holy": "Holy", + "shadow": "Shadow" + }, + "rogue": { + "assassination": "Assassination", + "combat": "Combat", + "subtlety": "Subtlety" + }, + "shaman": { + "elemental": "Elemental", + "enhancement": "Enhancement", + "restoration": "Restoration" + }, + "warlock": { + "affliction": "Affliction", + "demonology": "Demonology", + "destruction": "Destruction" + }, + "warrior": { + "arms": "Arms", + "fury": "Fury", + "protection": "Protection" + } + }, + "stats": { + "strength": "Strength", + "agility": "Agility", + "stamina": "Stamina", + "intellect": "Intellect", + "spirit": "Spirit", + "spell_hit": "Spell Hit", + "spell_crit": "Spell Crit", + "spell_haste": "Spell Haste", + "expertise": "Expertise", + "dodge": "Dodge", + "parry": "Parry", + "mastery": "Mastery", + "attack_power": "Attack Power", + "ranged_attack_power": "Ranged Attack Power", + "spell_power": "Spell Power", + "pvp_resilience": "PvP Resilience", + "pvp_power": "PvP Power", + "armor": "Armor", + "bonus_armor": "Bonus Armor", + "health": "Health", + "mana": "Mana", + "mp5": "MP5", + "main_hand_dps": "Main Hand DPS", + "off_hand_dps": "Off Hand DPS", + "ranged_dps": "Ranged DPS", + "block": "Block", + "melee_speed_multiplier": "Melee Speed Multiplier", + "ranged_speed_multiplier": "Ranged Speed Multiplier", + "cast_speed_multiplier": "Cast Speed Multiplier", + "melee_haste": "Melee Haste", + "ranged_haste": "Ranged Haste", + "melee_hit": "Melee Hit", + "melee_crit": "Melee Crit" + } + }, + "sim": { + "title": "Mists of Pandaria {spec} {class} simulator", + "description": "{spec} {class} simulations for World of Warcraft® Mists of Pandaria Classic." + }, + "gear": { + "title": "Gear" + }, + "settings": { + "title": "Settings" + }, + "talents": { + "title": "Talents" + }, + "rotation": { + "title": "Rotation" + }, + "results": { + "title": "Results" + }, + "import": { + "title": "Import" + }, + "export": { + "title": "Export" + }, + "sidebar": { + "iterations": "Iterations", + "buttons": { + "simulate": "Simulate", + "stat_weights": "Stat Weights", + "suggest_reforges": "Suggest Reforges" + }, + "header": { + "title": "WoWSims - Mists of Pandaria", + "phase": "{{phase}} - {{status}}" + }, + "character_stats": { + "title": "Stats", + "melee_crit_cap": "Melee Crit Cap", + "tooltip": { + "base": "Base:", + "gear": "Gear:", + "talents": "Talents:", + "buffs": "Buffs:", + "consumes": "Consumes:", + "bonus": "Bonus:", + "total": "Total:", + "glancing": "Glancing:", + "suppression": "Suppression:", + "to_hit_cap": "To Hit Cap:", + "to_exp_cap": "To Exp Cap:", + "spec_offsets": "Spec Offsets:", + "final_crit_cap": "Final Crit Cap:", + "can_raise_by": "Can Raise By:" + }, + "crit_cap": { + "exact": "Exact", + "over_by": "Over by", + "under_by": "Under by" + }, + "bonus_prefix": "Bonus", + "points_suffix": "Points", + "percent_suffix": "%" + } + } +} diff --git a/assets/locales/fr.json b/assets/locales/fr.json new file mode 100644 index 0000000000..9e804b72a8 --- /dev/null +++ b/assets/locales/fr.json @@ -0,0 +1,207 @@ +{ + "landing": { + "navigation": { + "home": "Accueil", + "simulations": "Simulations", + "about": "À propos", + "toggle": "Basculer la navigation" + }, + "simulations": { + "full_raid": "Simulation de raid complet" + }, + "home": { + "title": "WoWSims - Mists of Pandaria", + "description": "Un puissant outil de simulation pour World of Warcraft: Mists of Pandaria", + "welcomeDescription": "Bienvenue sur WoWSims - Mists of Pandaria ! Ce projet communautaire propose des simulations de classes et de raids pour World of Warcraft® Mists of Pandaria Classic, en collaboration avec les meilleurs théoriciens et représentants de classes." + }, + "header": { + "wowsims": "WoWSims", + "expansion": "Mists of Pandaria", + "supportDevs": "Soutenir nos développeurs" + } + }, + "common": { + "phases": { + "1": "Phase 1 (T14)", + "2": "Phase 2 (T15)", + "3": "Phase 3 (T16)" + }, + "status": { + "unlaunched": "Pas Encore Supporté", + "alpha": "Alpha", + "beta": "Bêta", + "launched": "Lancé" + }, + "classes": { + "death_knight": "Chevalier de la mort", + "druid": "Druide", + "hunter": "Chasseur", + "mage": "Mage", + "monk": "Moine", + "paladin": "Paladin", + "priest": "Prêtre", + "rogue": "Voleur", + "shaman": "Chaman", + "warlock": "Démoniste", + "warrior": "Guerrier" + }, + "specs": { + "death_knight": { + "blood": "Sang", + "frost": "Givre", + "unholy": "Impie" + }, + "druid": { + "balance": "Équilibre", + "feral": "Farouche", + "guardian": "Gardien", + "restoration": "Restauration" + }, + "hunter": { + "beast_mastery": "Maîtrise des bêtes", + "marksmanship": "Précision", + "survival": "Survie" + }, + "mage": { + "arcane": "Arcane", + "fire": "Feu", + "frost": "Givre" + }, + "monk": { + "brewmaster": "Maître brasseur", + "mistweaver": "Tisse-brume", + "windwalker": "Marche-vent" + }, + "paladin": { + "holy": "Sacré", + "protection": "Protection", + "retribution": "Vindicte" + }, + "priest": { + "discipline": "Discipline", + "holy": "Sacré", + "shadow": "Ombre" + }, + "rogue": { + "assassination": "Assassinat", + "combat": "Combat", + "subtlety": "Finesse" + }, + "shaman": { + "elemental": "Élémentaire", + "enhancement": "Amélioration", + "restoration": "Restauration" + }, + "warlock": { + "affliction": "Affliction", + "demonology": "Démonologie", + "destruction": "Destruction" + }, + "warrior": { + "arms": "Armes", + "fury": "Fureur", + "protection": "Protection" + } + }, + "stats": { + "strength": "Force", + "agility": "Agilité", + "stamina": "Endurance", + "intellect": "Intelligence", + "spirit": "Esprit", + "spell_hit": "Toucher des sorts", + "spell_crit": "Critique des sorts", + "spell_haste": "Hâte des sorts", + "expertise": "Expertise", + "dodge": "Esquive", + "parry": "Parade", + "mastery": "Maîtrise", + "attack_power": "Puissance d'attaque", + "ranged_attack_power": "Puissance d'attaque à distance", + "spell_power": "Puissance des sorts", + "pvp_resilience": "Résilience JcJ", + "pvp_power": "Puissance JcJ", + "armor": "Armure", + "bonus_armor": "Armure bonus", + "health": "Santé", + "mana": "Mana", + "mp5": "MP5", + "main_hand_dps": "DPS main principale", + "off_hand_dps": "DPS main gauche", + "ranged_dps": "DPS à distance", + "block": "Blocage", + "melee_speed_multiplier": "Multiplicateur de vitesse de mêlée", + "ranged_speed_multiplier": "Multiplicateur de vitesse à distance", + "cast_speed_multiplier": "Multiplicateur de vitesse d'incantation", + "melee_haste": "Hâte de mêlée", + "ranged_haste": "Hâte à distance", + "melee_hit": "Toucher de mêlée", + "melee_crit": "Critique de mêlée" + } + }, + "sim": { + "title": "Simulateur {spec} {class} - Mists of Pandaria", + "description": "Simulations {spec} {class} pour World of Warcraft® Mists of Pandaria Classic." + }, + "gear": { + "title": "Équipement" + }, + "settings": { + "title": "Paramètres" + }, + "talents": { + "title": "Talents" + }, + "rotation": { + "title": "Rotation" + }, + "results": { + "title": "Résultats" + }, + "import": { + "title": "Importer" + }, + "export": { + "title": "Exporter" + }, + "sidebar": { + "iterations": "Itérations", + "buttons": { + "simulate": "Simuler", + "stat_weights": "Poids des Statistiques", + "suggest_reforges": "Suggérer des Reforges" + }, + "header": { + "title": "WoWSims - Mists of Pandaria", + "phase": "{{phase}} - {{status}}" + }, + "character_stats": { + "title": "Statistiques", + "melee_crit_cap": "Plafond de Critique Mêlée", + "tooltip": { + "base": "Base :", + "gear": "Équipement :", + "talents": "Talents :", + "buffs": "Buffs :", + "consumes": "Consommables :", + "bonus": "Bonus :", + "total": "Total :", + "glancing": "Éraflement :", + "suppression": "Suppression :", + "to_hit_cap": "Vers le Plafond de Précision :", + "to_exp_cap": "Vers le Plafond d'Expertise :", + "spec_offsets": "Compensations de Spé :", + "final_crit_cap": "Plafond de Critique Final :", + "can_raise_by": "Peut Augmenter de :" + }, + "crit_cap": { + "exact": "Exact", + "over_by": "Au-dessus de", + "under_by": "En-dessous de" + }, + "bonus_prefix": "Bonus", + "points_suffix": "Points", + "percent_suffix": "%" + } + } +} diff --git a/docs/adding_sim.md b/docs/adding_sim.md new file mode 100644 index 0000000000..b74dcda870 --- /dev/null +++ b/docs/adding_sim.md @@ -0,0 +1,55 @@ +# Adding a Sim +So you want to make a new sim for your class/spec! The basic steps are as follows: + - [Create the proto interface between sim and UI.](#create-the-proto-interface-between-sim-and-ui) + - [Implement the UI.](#implement-the-ui) + - [Implement the sim.](#implement-the-sim) + - [Launch the site.](#launch-the-site) + + +## Create the proto interface between Sim and UI +This project uses [Google Protocol Buffers](https://developers.google.com/protocol-buffers/docs/gotutorial "https://developers.google.com/protocol-buffers/docs/gotutorial") to pass data between the sim and the UI. TLDR; Describe data structures in .proto files, and the tool can generate code in any programming language. It lets us avoid repeating the same code in our Go and Typescript worlds without losing type safety. + +For a new sim, make the following changes: + - Add a new value to the `Spec` enum in proto/common.proto. __NOTE: The name you give to this enum value is not just a name, it is used in our templating system. This guide will refer to this name as `$SPEC` elsewhere.__ + - Add a 'proto/YOUR_CLASS.proto' file if it doesn't already exist and add data messages containing all the class/spec-specific information needed to run your sim. + - Update the `PlayerOptions.spec` field in `proto/api.proto` to include your shiny new message as an option. + +That's it! Now when you run `make` there will be generated .go and .ts code in `sim/core/proto` and `ui/core/proto` respectively. If you aren't familiar with protos, take a quick look at them to see what's happening. + +## Implement the UI +The UI and sim can be done in either order, but it is generally recommended to build the UI first because it can help with debugging. The UI is very generalized and it doesn't take much work to build an entire sim UI using our templating system. To use it: + - Modify `ui/core/proto_utils/utils.ts` to include boilerplate for your `$SPEC` name if it isn't already there. + - Create a directory `ui/$SPEC`. So if your Spec enum value was named, `elemental_shaman`, create a directory, `ui/elemental_shaman`. + - Copy+paste from another spec's UI code. + - Modify all the files for your spec; most of the settings are fairly obvious, if you need anything complex just ask and we can help! + - Finally, add a rule to the `makefile` for the new sim site. Just copy from the other site rules already there and change the `$SPEC` names. + +No .html is needed, it will be generated based on `ui/index_template.html` and the `$SPEC` name. + +When you're ready to try out the site, run `make host` and navigate to `http://localhost:8080/mop/$SPEC`. + +## Implement the Sim +This step is where most of the magic happens. A few highlights to start understanding the sim code: + - `sim/wasm/main.go` This file is the actual main function, for the [.wasm binary](https://webassembly.org/ "https://webassembly.org/") used by the UI. You shouldn't ever need to touch this, but just know its here. + - `sim/core/api.go` This is where the action starts. This file implements the request/response messages defined in `proto/api.proto`. + - `sim/core/sim.go` Orchestrates everything. Main event loop is in `Simulation.RunOnce`. + - `sim/core/agent.go` An Agent can be thought of as the 'Player', i.e. the person controlling the game. This is the interface you'll be implementing. + - `sim/core/character.go` A Character holds all the stats/cooldowns/gear/etc common to any WoW character. Each Agent has a Character that it controls. + +Read through the core code and some examples from other classes/specs to get a feel for what's needed. Hopefully `sim/core` already includes what you need, but most classes have at least 1 unique mechanic so you may need to touch `core` as well. + +Finally, add your new sim to `RegisterAll()` in `sim/register_all.go`. + +Don't forget to write unit tests! Again, look at existing tests for examples. Run them with `make test` when you're ready. + +# Launch the site +When everything is ready for release, modify `ui/core/launched_sims.ts` and `ui/index.html` to include the new spec value. This will add the sim to the dropdown menu so anyone can find it from the existing sims. This will also remove the UI warning that the sim is under development. Now tell everyone about your new sim! + +# Add your spec to the raid sim +Don't touch the raid sim until the individual sim is ready for launch; anything in the raid sim is publicly accessible. To add your new spec to the raid sim, do the following: + - Add a reference to the individual sim in `ui/raid/tsconfig.json`. DO NOT FORGET THIS STEP or Typescipt will silently do very bad things. + - Import the individual sim's css file from `ui/raid/index.scss`. + - Update `ui/raid/presets.ts` to include a constructor factory in the `specSimFactories` variable and add configurations for new Players in the `playerPresets` variable. + +# Deployment +Thanks to the workflow defined in `.github/workflows/deploy.yml`, pushes to `master` automatically build and deploy a new site so there's nothing to do here. Sit back and appreciate your new sim! diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000000..bbb7c1fa11 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,78 @@ +# Commands +We use a makefile for our build system. These commands will usually be all you need while developing for this project: +```sh +# Installs a pre-commit git hook so that your go code is automatically formatted (if you don't use an IDE that supports that). If you want to manually format go code you can run make fmt. +# Also installs `air` to reload the dev servers automatically +make setup + +# Run all the tests. Currently only the backend sim has tests. +make test + +# Update the expected test results. This will need to be run after adding/removing any tests, and also if test results change due to code changes. +make update-tests + +# Host a local version of the UI at http://localhost:8080. Visit it by pointing a browser to +# http://localhost:8080/mop/YOUR_SPEC_HERE, where YOUR_SPEC_HERE is the directory under ui/ with your custom code. +# Recompiles the entire client before launching using `make dist/mop` +make host + +# With file-watching so the server auto-restarts and recompiles on Go or TS changes: +WATCH=1 make host + +# Delete all generated files (.pb.go and .ts proto files, and dist/) +make clean + +# Recompiles the ts only for the given spec (e.g. make host_elemental_shaman) +make host_$spec + +# Recompiles the `wowsimmop` server binary and runs it, hosting /dist directory at http://localhost:3333/mop. +# This is the fastest way to iterate on core go simulator code so you don't have to wait for client rebuilds. +# To rebuild client for a spec just do 'make $spec' and refresh browser. +make rundevserver + +# With file-watching so the server auto-restarts and recompiles on Go or TS changes: +WATCH=1 make rundevserver + +# The same as rundevserver, recompiles `wowsimmop` binary and runs it on port 3333. Instead of serving content from the dist folder, +# this command also runs `vite serve` to start the Vite dev server on port 5173 (or similar) and automatically reloads the page on .ts changes in less than a second. +# This allows for more rapid development, with sub second reloads on TS changes. This combines the benefits of `WATCH=1 make rundevserver` and `WATCH=1 make host` +# to create something that allows you to work in any part of the code with ease and speed. +# This might get rolled into `WATCH=1 make rundevserver` at some point. +WATCH=1 make devmode + +# This is just the same as rundevserver currently +make devmode + +# This command recompiles the workers in the /ui/worker folder for easier debugging/development +# Can be used with or without WATCH command +make webworkers + +# With file watch enabled +WATCH=1 make webworkers + +# Creates the 'wowsimmop' binary that can host the UI and run simulations natively (instead of with wasm). +# Builds the UI and the compiles it into the binary so that you can host the sim as a server instead of wasm on the client. +# It does this by first doing make dist/mop and then copying all those files to binary_dist/mop and loading all the files in that directory into its binary on compile. +make wowsimmop + +# Using the --usefs flag will instead of hosting the client built into the binary, it will host whatever code is found in the /dist directory. +# Use --wasm to host the client with the wasm simulator. +# The server also disables all caching so that refreshes should pickup any changed files in dist/. The client will still call to the server to run simulations so you can iterate more quickly on client changes. +# make dist/mop && ./wowsimmop --usefs would rebuild the whole client and host it. (you would have had to run `make devserver` to build the wowsimmop binary first.) +./wowsimmop --usefs + +# Generate code for the sim database (db.json). Only necessary if you changed the items generator. +# Useful only if you're actively working on the generator and have already run make db locally at least once. +make simdb + +# Generate data from WoW client files +# Requires dotnet 9 to run +# Uses tools/database/generator-settings.json for settings +# Also runs make simdb +# This is what you will use most of the time for generation +make db + +# Same as make db but from the ptr client +# Uses tools/database/ptr-generator-settings.json for settings +make ptrdb +``` diff --git a/docs/i18n_guide.md b/docs/i18n_guide.md new file mode 100644 index 0000000000..bac0096df2 --- /dev/null +++ b/docs/i18n_guide.md @@ -0,0 +1,145 @@ +# i18n Guide + +Hey there! 👋 This guide will help you work with translations in our WoW sim project. + +## Adding New Locale + +1. Create `{lang}.json` in `assets/locales`. For example, `de.json`. + +2. In `vite.config.mts`, add the file to the list of locales + +``` +function copyLocales() { + return { + ... + buildStart() { + const locales = [ + 'en.json', + 'de.json', <---- add your new locale file + ]; + ... + }, + } satisfies PluginOption; +} +``` + +3. In `\ui\i18n\config.ts`, import the locale file and add it to the resource list + +``` +import de from '../../assets/locales/de.json'; + +resources: { + en: { + translation: en + }, + de: { + translation: de + } + } +``` + +## Adding New Text + +All translations start in `en.json`. Here's how to structure it: + +```json +{ + "common": { + "buttons": { + "save": "Save", + "cancel": "Cancel" + } + }, + "gear": { + "equipment": { + "head": "Head", + "chest": "Chest" + } + } +} +``` + +### Quick Tips for Keys + +✅ Do this: +```json +{ + "talents": { + "specSelection": { + "chooseSpec": "Choose Spec", // Reusable! + "currentSpec": "Current Spec" + } + } +} +``` + +❌ Don't do this: +```json +{ + "btn1": "Save", // Too vague + "CANCEL_BUTTON": "Cancel", // Weird casing + "spec-name": "Fire", // No hyphens please + "talentPageTitle": "Talents Page" // Too specific +} +``` + +## Using Translations in Code + +### In TypeScript/TSX + +```typescript +import { i18n } from '../i18n/config'; + +// Simple usage +const saveText = i18n.t('common.buttons.save'); + +// With variables +const welcome = i18n.t('common.welcome', { name: playerName }); +``` + +### In Components + +```tsx +function SettingsMenu() { + return ( +
+

{i18n.t('settings.title')}

+ +
+ ); +} +``` + +## Pro Tips 🎮 + +1. **Keep it Reusable** + ```json + // ✅ Good - can use everywhere + "common.buttons.save": "Save" + + // ❌ Bad - too specific + "talentPageSaveButton": "Save" + ``` + +2. **Use Variables for Dynamic Stuff** + ```json + { + "character": { + "levelUp": "{{name}} hit level {{level}}!" // Nice! + } + } + ``` + +3. **Group Related Things** + ```json + { + "gear": { + "equipment": { + "head": "Head", + "chest": "Chest" + } + } + } + ``` + +That's it! Keep it simple and reusable. If you need to add new languages, just copy `en.json` and translate away! 🚀 \ No newline at end of file diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000000..217057811c --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,79 @@ +# Local Dev Installation + +This project has dependencies on Go >=1.23, protobuf-compiler and the corresponding Go plugins, and node >= 20. + +## Ubuntu +Do not use apt to install any dependencies, the versions they install are all too old. +Script below will curl latest versions and install them. +```sh +# Standard Go installation script +curl -O https://dl.google.com/go/go1.23.4.linux-amd64.tar.gz +sudo rm -rf /usr/local/go +sudo tar -C /usr/local -xzf go1.23.4.linux-amd64.tar.gz +echo 'export PATH=$PATH:/usr/local/go/bin' >> $HOME/.bashrc +echo 'export GOPATH=$HOME/go' >> $HOME/.bashrc +echo 'export PATH=$PATH:$GOPATH/bin' >> $HOME/.bashrc +source $HOME/.bashrc + +cd mop + +# Install protobuf compiler and Go plugins +sudo apt update && sudo apt upgrade +sudo apt install protobuf-compiler +go get -u -v google.golang.org/protobuf +go install google.golang.org/protobuf/cmd/protoc-gen-go@latest + +# Install node +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash +nvm install 20.13.1 + +# Install the npm package dependencies using node +npm install +``` + +## Docker +Alternatively, install Docker and your workflow will look something like this: +```sh +git clone https://github.com/wowsims/mop.git +cd mop + +# Build the docker image and install npm dependencies (only need to run these once). +docker build --tag wowsims-mop . +docker run --rm -v $(pwd):/mop wowsims-mop npm install + +# Now you can run the commands as shown in the Commands sections, preceding everything with, "docker run --rm -it -p 8080:8080 -v $(pwd):/mop wowsims-mop". +# For convenience, set this as an environment variable: +MOP_CMD="docker run --rm -it -p 8080:8080 -v $(pwd):/mop wowsims-mop" + +#For the watch commands assign this environment variable: +MOP_WATCH_CMD="docker run --rm -it -p 8080:8080 -p 3333:3333 -p 5173:5173 -e WATCH=1 -v $(pwd):/mop wowsims-mop" + +# ... do some coding on the sim ... + +# Run tests +$(echo $MOP_CMD) make test + +# ... do some coding on the UI ... + +# Host a local site +$(echo $MOP_CMD) make host +``` + +## Windows +If you want to develop on Windows, we recommend setting up a Ubuntu virtual machine (VM) or running Docker using [this guide](https://docs.docker.com/desktop/windows/wsl/ "https://docs.docker.com/desktop/windows/wsl/") and then following the Ubuntu or Docker instructions, respectively. + +If you prefer working natively: + +- Install [Go](https://go.dev/dl/s), [NVM Windows](https://github.com/coreybutler/nvm-windows), and [make](https://gnuwin32.sourceforge.net/packages/make.htm) (you can also install it through Chocolate). +- Install and use Node 20+ from NVM, for example `nvm install 20 && nvm use 20` +- Setup GO workspace following [this guide](https://www.freecodecamp.org/news/setting-up-go-programming-language-on-windows-f02c8c14e2f/) +- Download GO dependencies [protobuf](https://github.com/protocolbuffers/protobuf/releases), [gopls](https://github.com/golang/tools/releases), [air-verse](https://github.com/air-verse/air/releases), [protobuf-go](https://github.com/protocolbuffers/protobuf-go/releases), and [staticcheck](https://github.com/dominikh/go-tools/releases). Unzip them into your GO workspace directory. + +With all the dependencies setup, you should be able to run the `make` commands and compile the project. + +## Mac OS +* Docker is available in OS X as well, so in theory similar instructions should work for the Docker method +* You can also use the Ubuntu setup instructions as above to run natively, with a few modifications: + * You may need a different Go installer if `go1.18.3.linux-amd64.tar.gz` is not compatible with your system's architecture; you can do the Go install manually from `https://go.dev/doc/install`. + * OS X uses Homebrew instead of apt, so in order to install protobuf-compiler you'll instead need to run `brew install protobuf-c` (note the package name is also a little different than in apt). You might need to first update or upgrade brew. + * The provided install script for Node will not included a precompiled binary for OS X, but it's smart enough to compile one. Be ready for your CPU to melt on running `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash`. diff --git a/package-lock.json b/package-lock.json index 286e8160e1..302728a19b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,9 @@ "bootstrap": "^5.3.0", "chart.js": "^4.4.8", "clsx": "^2.1.1", + "i18next": "^25.0.2", + "i18next-browser-languagedetector": "^8.0.5", + "i18next-http-backend": "^3.0.2", "pako": "^2.0.4", "tippy.js": "^6.3.7", "tsx-vanilla": "^1.0.0", @@ -177,6 +180,18 @@ "node": ">=4" } }, + "node_modules/@babel/runtime": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@bufbuild/buf": { "version": "1.35.0", "resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.35.0.tgz", @@ -2410,6 +2425,15 @@ } } }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -3973,6 +3997,55 @@ "node": ">=6" } }, + "node_modules/i18next": { + "version": "25.0.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.0.2.tgz", + "integrity": "sha512-xWxgK8GAaPYkV9ia2tdgbtdM+qiC+ysVTBPvXhpCORU/+QkeQe3BSI7Crr+c4ZXULN1PfnXG/HY2n7HGx4KKBg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.10" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.5.tgz", + "integrity": "sha512-OstebRKqKiQw8xEvQF5aRyUujsCatanj7Q9eo5iiH2gJpoXGZ7483ol3sVBwfqbobTQPNH1J+NAyJ1aCQoEC+w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -4810,6 +4883,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/normalize-package-data": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", @@ -5569,6 +5662,12 @@ "node": ">=8" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", @@ -6659,6 +6758,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/trim-newlines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", @@ -6830,7 +6935,7 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7228,6 +7333,22 @@ "dev": true, "license": "MIT" }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 3e423c24a8..c5a23659bb 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "bootstrap": "^5.3.0", "chart.js": "^4.4.8", "clsx": "^2.1.1", + "i18next": "^25.0.2", + "i18next-browser-languagedetector": "^8.0.5", + "i18next-http-backend": "^3.0.2", "pako": "^2.0.4", "tippy.js": "^6.3.7", "tsx-vanilla": "^1.0.0", diff --git a/sim/druid/guardian/apl_values.go b/sim/druid/guardian/apl_values.go index f7aa4f9bc9..727f4f3342 100644 --- a/sim/druid/guardian/apl_values.go +++ b/sim/druid/guardian/apl_values.go @@ -7,7 +7,6 @@ import ( "github.com/wowsims/mop/sim/core/proto" ) - func (bear *GuardianDruid) NewAPLAction(rot *core.APLRotation, config *proto.APLAction) core.APLActionImpl { switch config.Action.(type) { case *proto.APLAction_GuardianHotwDpsRotation: @@ -70,7 +69,7 @@ func (action *APLActionGuardianHotwDpsRotation) Execute(sim *core.Simulation) { curCp := bear.ComboPoints() ripDot := bear.Rip.CurDot() - ripNow := (curCp == 5) && (!ripDot.IsActive() || (ripDot.RemainingDuration(sim) < ripDot.BaseTickLength)) + ripNow := (curCp == 5) && (!ripDot.IsActive() || (ripDot.RemainingDuration(sim) < ripDot.BaseTickLength)) rakeDot := bear.Rake.CurDot() rakeNow := !rakeDot.IsActive() || (rakeDot.RemainingDuration(sim) < rakeDot.BaseTickLength) @@ -109,7 +108,7 @@ func (action *APLActionGuardianHotwDpsRotation) Execute(sim *core.Simulation) { bear.Wrath.Cast(sim, bear.CurrentTarget) return } - + action.nextActionAt = sim.CurrentTime + poolingTime + bear.ReactionTime bear.WaitUntil(sim, action.nextActionAt) } diff --git a/sim/druid/rejuvenation.go b/sim/druid/rejuvenation.go index e669d75a3c..e350b79a8c 100644 --- a/sim/druid/rejuvenation.go +++ b/sim/druid/rejuvenation.go @@ -8,13 +8,13 @@ import ( const ( RejuvenationBonusCoeff = 0.39199998975 - RejuvenationCoeff = 3.86800003052 + RejuvenationCoeff = 3.86800003052 ) func (druid *Druid) registerRejuvenationSpell() { baseTickDamage := RejuvenationCoeff * druid.ClassSpellScaling - druid.Rejuvenation = druid.RegisterSpell(Humanoid | Moonkin, core.SpellConfig{ + druid.Rejuvenation = druid.RegisterSpell(Humanoid|Moonkin, core.SpellConfig{ ActionID: core.ActionID{SpellID: 774}, SpellSchool: core.SpellSchoolNature, ProcMask: core.ProcMaskSpellHealing, diff --git a/sim/druid/talents.go b/sim/druid/talents.go index 9945354b27..82dd220ea8 100644 --- a/sim/druid/talents.go +++ b/sim/druid/talents.go @@ -48,7 +48,7 @@ func (druid *Druid) registerNaturesVigil() { ThreatMultiplier: 0, ApplyEffects: func(sim *core.Simulation, target *core.Unit, spell *core.Spell) { - spell.CalcAndDealHealing(sim, target, 0.25 * smartHealStrength, spell.OutcomeHealing) + spell.CalcAndDealHealing(sim, target, 0.25*smartHealStrength, spell.OutcomeHealing) }, }) @@ -76,10 +76,10 @@ func (druid *Druid) registerNaturesVigil() { } // Assume that the target is randomly selected from 25 raid members. - if sim.Proc(1.0 / 25.0, "Nature's Vigil") { + if sim.Proc(1.0/25.0, "Nature's Vigil") { smartHealSpell.Cast(sim, aura.Unit) } else if numAllyTargets > 0 { - targetIdx := 1 + int(sim.RandomFloat("Nature's Vigil") * numAllyTargets) + targetIdx := 1 + int(sim.RandomFloat("Nature's Vigil")*numAllyTargets) smartHealSpell.Cast(sim, sim.Raid.AllPlayerUnits[targetIdx]) } diff --git a/sim/druid/wrath.go b/sim/druid/wrath.go index 61aeaee4a2..6278405eec 100644 --- a/sim/druid/wrath.go +++ b/sim/druid/wrath.go @@ -33,7 +33,7 @@ func (druid *Druid) registerWrathSpell() { ModifyCast: func(sim *core.Simulation, spell *core.Spell, curCast *core.Cast) { hastedCastTime := spell.Unit.ApplyCastSpeedForSpell(curCast.CastTime, spell).Round(time.Millisecond) - spell.Unit.AutoAttacks.StopMeleeUntil(sim, sim.CurrentTime + hastedCastTime) + spell.Unit.AutoAttacks.StopMeleeUntil(sim, sim.CurrentTime+hastedCastTime) }, }, diff --git a/sim/encounters/msv/garajal_ai.go b/sim/encounters/msv/garajal_ai.go index b29dc0ed03..bc318e5a42 100644 --- a/sim/encounters/msv/garajal_ai.go +++ b/sim/encounters/msv/garajal_ai.go @@ -71,7 +71,7 @@ func createGarajalHeroicPreset(raidPrefix string, raidSize int32, bossHealth flo AI: makeGarajalAI(raidSize, false), }) - targetPathNames = append(targetPathNames, raidPrefix + "/" + addName) + targetPathNames = append(targetPathNames, raidPrefix+"/"+addName) core.AddPresetEncounter(bossName, targetPathNames) } @@ -316,7 +316,7 @@ func (ai *GarajalAI) registerTankSwapAuras() { } vengeanceAura.ApplyOnStacksChange(func(aura *core.Aura, sim *core.Simulation, _ int32, newStacks int32) { - if !ai.VoodooDollsAura.IsActive() && !ai.BanishmentAura.IsActive() && (sim.CurrentTime - lastTaunt > time.Second * 25) && (newStacks < priorVengeanceEstimate/2) { + if !ai.VoodooDollsAura.IsActive() && !ai.BanishmentAura.IsActive() && (sim.CurrentTime-lastTaunt > time.Second*25) && (newStacks < priorVengeanceEstimate/2) { aura.Activate(sim) aura.SetStacks(sim, priorVengeanceEstimate/2) lastTaunt = sim.CurrentTime @@ -417,7 +417,7 @@ func (ai *GarajalAI) registerSpiritualGrasp() { } func (ai *GarajalAI) rollNextSpiritualGraspTime(sim *core.Simulation) time.Duration { - return sim.CurrentTime + core.DurationFromSeconds(-math.Log(sim.RandomFloat("Spiritual Grasp")) * ai.meanGraspIntervalSeconds) + return sim.CurrentTime + core.DurationFromSeconds(-math.Log(sim.RandomFloat("Spiritual Grasp"))*ai.meanGraspIntervalSeconds) } func (ai *GarajalAI) registerFrenzy() { diff --git a/ui/core/components/character_stats.tsx b/ui/core/components/character_stats.tsx index 3c04d87dfe..1b7cb2cfa2 100644 --- a/ui/core/components/character_stats.tsx +++ b/ui/core/components/character_stats.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx'; import tippy from 'tippy.js'; import { ref } from 'tsx-vanilla'; +import i18n from '../../i18n/config.js'; import * as Mechanics from '../constants/mechanics.js'; import { IndividualSimUI } from '../individual_sim_ui'; import { Player } from '../player.js'; @@ -55,7 +56,7 @@ export class CharacterStats extends Component { const label = document.createElement('label'); label.classList.add('character-stats-label'); - label.textContent = 'Stats'; + label.textContent = i18n.t('sidebar.character_stats.title'); this.rootElem.appendChild(label); const table = document.createElement('table'); @@ -153,7 +154,7 @@ export class CharacterStats extends Component { if (unitStat.isPseudoStat() && unitStat.getPseudoStat() === PseudoStat.PseudoStatPhysicalCritPercent && this.shouldShowMeleeCritCap(player)) { const critCapRow = ( - Melee Crit Cap + {i18n.t('sidebar.character_stats.melee_crit_cap')} {/* Hacky placeholder for spacing */} @@ -349,33 +350,33 @@ export class CharacterStats extends Component { const tooltipContent = (
- Base: + {i18n.t('sidebar.character_stats.tooltip.base')} {this.statDisplayString(baseDelta, unitStat, true)}
- Gear: + {i18n.t('sidebar.character_stats.tooltip.gear')} {this.statDisplayString(gearDelta, unitStat)}
- Talents: + {i18n.t('sidebar.character_stats.tooltip.talents')} {this.statDisplayString(talentsDelta, unitStat)}
- Buffs: + {i18n.t('sidebar.character_stats.tooltip.buffs')} {this.statDisplayString(buffsDelta, unitStat)}
- Consumes: + {i18n.t('sidebar.character_stats.tooltip.consumes')} {this.statDisplayString(consumesDelta, unitStat)}
{bonusStatValue !== 0 && (
- Bonus: + {i18n.t('sidebar.character_stats.tooltip.bonus')} {this.statDisplayString(bonusStats, unitStat)}
)}
- Total: + {i18n.t('sidebar.character_stats.tooltip.total')} {this.statDisplayString(finalStats, unitStat, true)}
@@ -460,7 +461,7 @@ export class CharacterStats extends Component { const hideRootRating = rootRatingValue === null || (rootRatingValue === 0 && derivedPercentOrPointsValue !== null); const rootRatingString = hideRootRating ? '' : String(Math.round(rootRatingValue)); - const percentOrPointsSuffix = unitStat.equalsStat(Stat.StatMasteryRating) ? ' Points' : '%'; + const percentOrPointsSuffix = unitStat.equalsStat(Stat.StatMasteryRating) ? ` ${i18n.t('sidebar.character_stats.points_suffix')}` : i18n.t('sidebar.character_stats.percent_suffix'); const percentOrPointsString = derivedPercentOrPointsValue === null ? '' : `${derivedPercentOrPointsValue.toFixed(2)}` + percentOrPointsSuffix; const wrappedPercentOrPointsString = hideRootRating || derivedPercentOrPointsValue === null ? percentOrPointsString : ` (${percentOrPointsString})`; return rootRatingString + wrappedPercentOrPointsString; @@ -478,7 +479,7 @@ export class CharacterStats extends Component { ); - tippy(iconRef.value!, { content: `Bonus ${statName}` }); + tippy(iconRef.value!, { content: `${i18n.t('sidebar.character_stats.bonus_prefix')} ${statName}` }); tippy(linkRef.value!, { interactive: true, trigger: 'click', @@ -487,7 +488,7 @@ export class CharacterStats extends Component { onShow: instance => { const picker = new NumberPicker(null, this.player, { id: `character-bonus-stat-${rootStat}`, - label: `Bonus ${statName}`, + label: `${i18n.t('sidebar.character_stats.bonus_prefix')} ${statName}`, extraCssClasses: ['mb-0'], changedEvent: (player: Player) => player.bonusStatsChangeEmitter, getValue: (player: Player) => player.getBonusStats().getStat(rootStat), @@ -512,10 +513,10 @@ export class CharacterStats extends Component { const playerCritCapDelta = player.getMeleeCritCap(); if (playerCritCapDelta === 0.0) { - return 'Exact'; + return i18n.t('sidebar.character_stats.crit_cap.exact'); } - const prefix = playerCritCapDelta > 0 ? 'Over by ' : 'Under by '; + const prefix = playerCritCapDelta > 0 ? i18n.t('sidebar.character_stats.crit_cap.over_by') : i18n.t('sidebar.character_stats.crit_cap.under_by'); return `${prefix} ${Math.abs(playerCritCapDelta).toFixed(2)}%`; } } diff --git a/ui/core/components/individual_sim_ui/gear_tab.ts b/ui/core/components/individual_sim_ui/gear_tab.ts index 24758f4825..3993bb3d42 100644 --- a/ui/core/components/individual_sim_ui/gear_tab.ts +++ b/ui/core/components/individual_sim_ui/gear_tab.ts @@ -1,3 +1,4 @@ +import i18n from '../../../i18n/config'; import { IndividualSimUI } from '../../individual_sim_ui'; import { Player } from '../../player'; import { EquipmentSpec, UnitStats } from '../../proto/common'; @@ -18,7 +19,7 @@ export class GearTab extends SimTab { readonly rightPanel: HTMLElement; constructor(parentElem: HTMLElement, simUI: IndividualSimUI) { - super(parentElem, simUI, { identifier: 'gear-tab', title: 'Gear' }); + super(parentElem, simUI, { identifier: 'gear-tab', title: i18n.t('gear.title') }); this.simUI = simUI; this.leftPanel = document.createElement('div'); diff --git a/ui/core/components/individual_sim_ui/rotation_tab.tsx b/ui/core/components/individual_sim_ui/rotation_tab.tsx index 83a4e8f216..492e7af3dd 100644 --- a/ui/core/components/individual_sim_ui/rotation_tab.tsx +++ b/ui/core/components/individual_sim_ui/rotation_tab.tsx @@ -1,5 +1,6 @@ import { ref } from 'tsx-vanilla'; +import i18n from '../../../i18n/config'; import * as Tooltips from '../../constants/tooltips'; import { IndividualSimUI, InputSection } from '../../individual_sim_ui'; import { Player } from '../../player'; @@ -29,7 +30,7 @@ export class RotationTab extends SimTab { readonly leftCol: HTMLElement = this.buildColumn(1, 'rotation-tab-col'); constructor(parentElem: HTMLElement, simUI: IndividualSimUI) { - super(parentElem, simUI, { identifier: 'rotation-tab', title: 'Rotation' }); + super(parentElem, simUI, { identifier: 'rotation-tab', title: i18n.t('rotation.title') }); this.simUI = simUI; this.leftPanel = (
) as HTMLElement; diff --git a/ui/core/components/individual_sim_ui/settings_tab.tsx b/ui/core/components/individual_sim_ui/settings_tab.tsx index f1854e2cbe..eb9205c20b 100644 --- a/ui/core/components/individual_sim_ui/settings_tab.tsx +++ b/ui/core/components/individual_sim_ui/settings_tab.tsx @@ -1,3 +1,4 @@ +import i18n from '../../../i18n/config'; import * as Tooltips from '../../constants/tooltips.js'; import { Encounter } from '../../encounter.js'; import { IndividualSimUI, InputSection } from '../../individual_sim_ui.jsx'; @@ -35,7 +36,7 @@ export class SettingsTab extends SimTab { readonly column4?: HTMLElement; constructor(parentElem: HTMLElement, simUI: IndividualSimUI) { - super(parentElem, simUI, { identifier: 'settings-tab', title: 'Settings' }); + super(parentElem, simUI, { identifier: 'settings-tab', title: i18n.t('settings.title') }); this.simUI = simUI; this.leftPanel = document.createElement('div'); diff --git a/ui/core/components/individual_sim_ui/talents_tab.tsx b/ui/core/components/individual_sim_ui/talents_tab.tsx index e1158d52c5..a8cb0c66d0 100644 --- a/ui/core/components/individual_sim_ui/talents_tab.tsx +++ b/ui/core/components/individual_sim_ui/talents_tab.tsx @@ -1,3 +1,4 @@ +import i18n from '../../../i18n/config'; import { IndividualSimUI } from '../../individual_sim_ui'; import { Player } from '../../player'; import { Class, Glyphs, Spec } from '../../proto/common'; @@ -17,7 +18,7 @@ export class TalentsTab extends SimTab { readonly rightPanel: HTMLElement; constructor(parentElem: HTMLElement, simUI: IndividualSimUI) { - super(parentElem, simUI, { identifier: 'talents-tab', title: 'Talents' }); + super(parentElem, simUI, { identifier: 'talents-tab', title: i18n.t('talents.title') }); this.simUI = simUI; this.leftPanel = (
) as HTMLElement; diff --git a/ui/core/components/raid_sim_action.tsx b/ui/core/components/raid_sim_action.tsx index b8384b8bef..716b16fcdb 100644 --- a/ui/core/components/raid_sim_action.tsx +++ b/ui/core/components/raid_sim_action.tsx @@ -1,6 +1,7 @@ import clsx from 'clsx'; import tippy from 'tippy.js'; +import i18n from '../../i18n/config.js'; import { TOOLTIP_METRIC_LABELS } from '../constants/tooltips'; import { DistributionMetrics as DistributionMetricsProto, ProgressMetrics, Raid as RaidProto } from '../proto/api'; import { Encounter as EncounterProto, Spec } from '../proto/common'; @@ -15,7 +16,7 @@ export function addRaidSimAction(simUI: SimUI): RaidSimResultsManager { const resultsViewer = simUI.resultsViewer; let isRunning = false; let waitAbort = false; - simUI.addAction('Simulate', 'dps-action', async ev => { + simUI.addAction(i18n.t('sidebar.buttons.simulate'), 'dps-action', async ev => { const button = ev.target as HTMLButtonElement; button.disabled = true; if (!isRunning) { diff --git a/ui/core/components/settings_menu.tsx b/ui/core/components/settings_menu.tsx index 73be7dea95..0de6212c58 100644 --- a/ui/core/components/settings_menu.tsx +++ b/ui/core/components/settings_menu.tsx @@ -1,8 +1,7 @@ import tippy from 'tippy.js'; import { ref } from 'tsx-vanilla'; -import { wowheadSupportedLanguages } from '../constants/lang.js'; -import { setCurrentLang } from '../locale_service'; +import { setLang, supportedLanguages } from '../../i18n/locale_service'; import { Sim } from '../sim.js'; import { SimUI } from '../sim_ui.js'; import { EventID, TypedEvent } from '../typed_event.js'; @@ -102,7 +101,7 @@ export class SettingsMenu extends BaseModal { } if (language.value) { - const langs = Object.keys(wowheadSupportedLanguages); + const langs = Object.keys(supportedLanguages); const defaultLang = langs.indexOf('en'); const languagePicker = new EnumPicker(language.value, this.simUI.sim, { id: 'simui-language-picker', @@ -110,7 +109,7 @@ export class SettingsMenu extends BaseModal { labelTooltip: 'Controls the language for Wowhead tooltips.', values: langs.map((lang, i) => { return { - name: wowheadSupportedLanguages[lang], + name: supportedLanguages[lang], value: i, }; }), @@ -121,11 +120,11 @@ export class SettingsMenu extends BaseModal { }, setValue: (eventID: EventID, sim: Sim, newValue: number) => { sim.setLanguage(eventID, langs[newValue] || 'en'); - setCurrentLang(langs[newValue] || 'en'); + setLang(langs[newValue] || 'en'); }, }); // Refresh page after language change, to apply the changes. - languagePicker.changeEmitter.on(() => setTimeout(() => location.reload(), 100)); + languagePicker.changeEmitter.on(() => setTimeout(() => location.reload(), 300)); } if (showThreatMetrics.value) diff --git a/ui/core/components/sim_header.tsx b/ui/core/components/sim_header.tsx index 2657d8d014..04c86a98b9 100644 --- a/ui/core/components/sim_header.tsx +++ b/ui/core/components/sim_header.tsx @@ -2,6 +2,7 @@ import clsx from 'clsx'; import tippy, { ReferenceElement as TippyReferenceElement } from 'tippy.js'; import { ref } from 'tsx-vanilla'; +import i18n from '../../i18n/config'; import { REPO_CHOOSE_NEW_ISSUE_URL, REPO_RELEASES_URL } from '../constants/other'; import { SimUI } from '../sim_ui'; import { isLocal, noop } from '../utils'; @@ -238,13 +239,13 @@ export class SimHeader extends Component {
      diff --git a/ui/core/components/sim_title_dropdown.tsx b/ui/core/components/sim_title_dropdown.tsx index 25d2b1d2d7..a36bf46f1d 100644 --- a/ui/core/components/sim_title_dropdown.tsx +++ b/ui/core/components/sim_title_dropdown.tsx @@ -1,6 +1,9 @@ import clsx from 'clsx'; import { ref } from 'tsx-vanilla'; +import i18n from '../../i18n/config.js'; +import { translateStatus } from '../../i18n/entity_mapping'; +import { translatePlayerClass, translatePlayerSpec } from '../../i18n/localization'; import { LaunchStatus, raidSimStatus, simLaunchStatuses } from '../launched_sims.js'; import { PlayerClass } from '../player_class.js'; import { PlayerClasses } from '../player_classes/index.js'; @@ -95,7 +98,7 @@ export class SimTitleDropdown extends Component {
      - WoWSims - Mists of Pandaria + {i18n.t('sidebar.header.title')} {data.type === 'Raid' && raidSimLabel} {data.type === 'Spec' && PlayerSpecs.getFullSpecName(data.spec)} @@ -130,7 +133,7 @@ export class SimTitleDropdown extends Component {
      - {klass.friendlyName} + {translatePlayerClass(klass)}
      @@ -143,8 +146,8 @@ export class SimTitleDropdown extends Component {
      - {PlayerSpecs.getPlayerClass(spec).friendlyName} - {spec.friendlyName} + {translatePlayerClass(PlayerSpecs.getPlayerClass(spec))} + {translatePlayerSpec(spec)} {this.launchStatusLabel({ type: 'Spec', spec: spec })}
      @@ -153,23 +156,17 @@ export class SimTitleDropdown extends Component { } private launchStatusLabel(data: SpecOptions | RaidOptions) { - if ( - (data.type === 'Raid' && raidSimStatus.status === LaunchStatus.Launched) - ) - return null; + if (data.type === 'Raid' && raidSimStatus.status === LaunchStatus.Launched) return null; const status = data.type === 'Raid' ? raidSimStatus.status : simLaunchStatuses[data.spec.specID as Spec].status; const phase = data.type === 'Raid' ? raidSimStatus.phase : simLaunchStatuses[data.spec.specID as Spec].phase; return ( - {status === LaunchStatus.Unlaunched ? ( - <>Not Yet Supported - ) : ( - <> - Phase {phase} - {LaunchStatus[status]} - - )} + {i18n.t('sidebar.header.phase', { + phase: i18n.t(`common.phases.${phase}`), + status: translateStatus(status), + })} ); } diff --git a/ui/core/components/stat_weights_action.tsx b/ui/core/components/stat_weights_action.tsx index 15a7887d7f..8aa3ad6a4e 100644 --- a/ui/core/components/stat_weights_action.tsx +++ b/ui/core/components/stat_weights_action.tsx @@ -2,6 +2,7 @@ import clsx from 'clsx'; import tippy from 'tippy.js'; import { ref } from 'tsx-vanilla'; +import i18n from '../../i18n/config.js'; import { CURRENT_API_VERSION } from '../constants/other'; import { IndividualSimUI } from '../individual_sim_ui.jsx'; import { Player } from '../player.js'; @@ -132,7 +133,7 @@ export class StatWeightActionSettings { export const addStatWeightsAction = (simUI: IndividualSimUI, settings: StatWeightActionSettings) => { const epWeightsModal = new EpWeightsMenu(simUI, settings); - simUI.addAction('Stat Weights', 'ep-weights-action', () => { + simUI.addAction(i18n.t('sidebar.buttons.stat_weights'), 'ep-weights-action', () => { epWeightsModal.open(); }); diff --git a/ui/core/components/suggest_reforges_action.tsx b/ui/core/components/suggest_reforges_action.tsx index d913adf48c..ed970b5bc4 100644 --- a/ui/core/components/suggest_reforges_action.tsx +++ b/ui/core/components/suggest_reforges_action.tsx @@ -3,6 +3,7 @@ import tippy, { hideAll } from 'tippy.js'; import { ref } from 'tsx-vanilla'; import { Constraint, greaterEq, lessEq, Model, Options, Solution, solve } from 'yalps'; +import i18n from '../../i18n/config.js'; import * as Mechanics from '../constants/mechanics.js'; import { IndividualSimUI } from '../individual_sim_ui'; import { Player } from '../player'; @@ -200,7 +201,7 @@ export class ReforgeOptimizer { this.enableBreakpointLimits = !!options?.enableBreakpointLimits; const startReforgeOptimizationEntry: ActionGroupItem = { - label: 'Suggest Reforges', + label: i18n.t('sidebar.buttons.suggest_reforges'), cssClass: 'suggest-reforges-action-button flex-grow-1', onClick: async ({ currentTarget }) => { const button = currentTarget as HTMLButtonElement; diff --git a/ui/core/constants/lang.ts b/ui/core/constants/lang.ts deleted file mode 100644 index 850cd29dc7..0000000000 --- a/ui/core/constants/lang.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { getCurrentLang, setCurrentLang } from '../locale_service'; - -export const wowheadSupportedLanguages: Record = { - 'en': 'English', - 'cn': '简体中文', - 'de': 'Deutsch', - 'es': 'Español', - 'fr': 'Français', - 'it': 'Italiano', - 'ko': '한국어', - 'pt': 'Português Brasileiro', - 'ru': 'Русский', -}; - -// Returns a 2-letter language code if it is a wowhead-supported language, or '' otherwise. -export function getBrowserLanguageCode(): string { - const browserLang = (navigator.language || '').substring(0, 2); - if (Object.keys(wowheadSupportedLanguages).includes(browserLang)) { - return browserLang; - } else { - return ''; - } -} - -export function getLanguageCode(): string { - return getCurrentLang(); -} - -export function getWowheadLanguagePrefix(): string { - const lang = getCurrentLang(); - return lang === 'en' ? '' : `${lang}/`; -} - -export function setLanguageCode(newLang: string) { - setCurrentLang(newLang); -} diff --git a/ui/core/individual_sim_ui.tsx b/ui/core/individual_sim_ui.tsx index 2fcb799d92..0f561d5a0f 100644 --- a/ui/core/individual_sim_ui.tsx +++ b/ui/core/individual_sim_ui.tsx @@ -1,3 +1,4 @@ +import i18n from '../i18n/config'; import { CharacterStats, StatMods, StatWrites } from './components/character_stats'; import { ContentBlock } from './components/content_block'; import { EmbeddedDetailedResults } from './components/detailed_results'; @@ -477,7 +478,7 @@ export abstract class IndividualSimUI extends SimUI { private addDetailedResultsTab() { const detailedResults = (
      ) as HTMLElement; - this.addTab('Results', 'detailed-results-tab', detailedResults); + this.addTab(i18n.t('results.title'), 'detailed-results-tab', detailedResults); new EmbeddedDetailedResults(detailedResults, this, this.raidSimResultsManager!); } diff --git a/ui/core/locale_service.ts b/ui/core/locale_service.ts deleted file mode 100644 index 32f34348a0..0000000000 --- a/ui/core/locale_service.ts +++ /dev/null @@ -1,19 +0,0 @@ -const LOCALE_KEY = 'wowsims_locale'; - -export function getCurrentLang(): string { - const record = localStorage.getItem(LOCALE_KEY); - if (record) { - try { - return JSON.parse(record).lang || 'en'; - } catch { - return 'en'; - } - } - return 'en'; -} - -export function setCurrentLang(lang: string): void { - localStorage.setItem(LOCALE_KEY, JSON.stringify({ lang })); -} - -export {}; diff --git a/ui/core/player_specs/index.ts b/ui/core/player_specs/index.ts index 8bc9cf1a25..3ba709319d 100644 --- a/ui/core/player_specs/index.ts +++ b/ui/core/player_specs/index.ts @@ -1,3 +1,4 @@ +import { translatePlayerClass, translatePlayerSpec } from '../../i18n/localization'; import { LOCAL_STORAGE_PREFIX } from '../constants/other'; import { PlayerClass } from '../player_class'; import { PlayerClasses } from '../player_classes'; @@ -87,7 +88,9 @@ export const PlayerSpecs = { ...WarriorSpecs, getPlayerClass, getFullSpecName: (playerSpec: PlayerSpec): string => { - return `${playerSpec.friendlyName} ${getPlayerClass(playerSpec).friendlyName}`; + const translatedSpec = translatePlayerSpec(playerSpec); + const translatedClass = translatePlayerClass(getPlayerClass(playerSpec)); + return `${translatedSpec} ${translatedClass}`; }, getSpecNumber: (playerSpec: PlayerSpec): number => { return Object.values(getPlayerClass(playerSpec).specs).findIndex(spec => spec == playerSpec) ?? 0; diff --git a/ui/core/proto_utils/action_id.ts b/ui/core/proto_utils/action_id.ts index d736cda131..4edfaa67dd 100644 --- a/ui/core/proto_utils/action_id.ts +++ b/ui/core/proto_utils/action_id.ts @@ -1,9 +1,8 @@ -import { getWowheadLanguagePrefix } from '../constants/lang'; import { CHARACTER_LEVEL, MAX_CHALLENGE_MODE_ILVL } from '../constants/mechanics'; import { ActionID as ActionIdProto, ItemLevelState, ItemRandomSuffix, OtherAction, ReforgeStat } from '../proto/common'; import { ResourceType } from '../proto/spell'; import { IconData, UIItem as Item } from '../proto/ui'; -import { buildWowheadTooltipDataset, WowheadTooltipItemParams, WowheadTooltipSpellParams } from '../wowhead'; +import { buildWowheadTooltipDataset, getWowheadLanguagePrefix, WowheadTooltipItemParams, WowheadTooltipSpellParams } from '../wowhead'; import { Database } from './database'; // If true uses wotlkdb.com, else uses wowhead.com. diff --git a/ui/core/proto_utils/names.ts b/ui/core/proto_utils/names.ts index 709bcb016d..35bb225d19 100644 --- a/ui/core/proto_utils/names.ts +++ b/ui/core/proto_utils/names.ts @@ -122,20 +122,6 @@ export function getStatName(stat: Stat): string { } } -export function getClassPseudoStatName(pseudoStat: PseudoStat, playerClass: Class): string { - const genericName = PseudoStat[pseudoStat] - .split(/(? this.updateCharacterStats(eventID)); - this.language = getCurrentLang(); + this.language = getLang(); } waitForInit(): Promise { @@ -766,11 +765,9 @@ export class Sim { return this.language; } setLanguage(eventID: EventID, newLanguage: string) { - newLanguage = newLanguage || getBrowserLanguageCode(); + newLanguage = newLanguage || 'en'; if (newLanguage != this.language) { this.language = newLanguage; - setCurrentLang(this.language); - setLanguageCode(this.language); this.languageChangeEmitter.emit(eventID); } } diff --git a/ui/core/sim_ui.tsx b/ui/core/sim_ui.tsx index 92d71a96d5..fcee2e037b 100644 --- a/ui/core/sim_ui.tsx +++ b/ui/core/sim_ui.tsx @@ -1,6 +1,7 @@ import clsx from 'clsx'; import { ref } from 'tsx-vanilla'; +import i18n from '../i18n/config.js'; import { BaseModal } from './components/base_modal.jsx'; import { Component } from './components/component.js'; import { NoticeLocalSim } from './components/individual_sim_ui/notice_local_sim.jsx'; @@ -170,7 +171,7 @@ export abstract class SimUI extends Component { this.iterationsPicker = new NumberPicker(this.simActionsContainer, this.sim, { id: 'simui-iterations', - label: 'Iterations', + label: i18n.t('sidebar.iterations'), extraCssClasses: ['iterations-picker', 'within-raid-sim-hide'], changedEvent: (sim: Sim) => sim.iterationsChangeEmitter, getValue: (sim: Sim) => sim.getIterations(), diff --git a/ui/core/wowhead.ts b/ui/core/wowhead.ts index 92d5c5613c..e0853fc5fc 100644 --- a/ui/core/wowhead.ts +++ b/ui/core/wowhead.ts @@ -1,4 +1,4 @@ -import { getLanguageCode } from './constants/lang'; +import { getLang } from '../i18n/locale_service'; import { CHARACTER_LEVEL } from './constants/mechanics'; import { Database } from './proto_utils/database'; @@ -86,7 +86,7 @@ export type WowheadTooltipSpellParams = { export const WOWHEAD_EXPANSION_ENV = 15; export const buildWowheadTooltipDataset = async (options: WowheadTooltipItemParams | WowheadTooltipSpellParams) => { - const lang = getLanguageCode(); + const lang = getLang(); const params = new URLSearchParams(); const langPrefix = lang && lang != 'en' ? lang + '.' : ''; params.set('domain', `${langPrefix}mop-classic`); @@ -137,3 +137,8 @@ export const buildWowheadTooltipDataset = async (options: WowheadTooltipItemPara return decodeURIComponent(params.toString()); }; + +export function getWowheadLanguagePrefix(): string { + const lang = getLang(); + return lang === 'en' ? '' : `${lang}/`; +} diff --git a/ui/i18n/config.ts b/ui/i18n/config.ts new file mode 100644 index 0000000000..562d1a4095 --- /dev/null +++ b/ui/i18n/config.ts @@ -0,0 +1,26 @@ +import i18n from 'i18next'; + +import en from '../../assets/locales/en.json'; +import fr from '../../assets/locales/fr.json'; +import { getLang } from './locale_service'; + +// eslint-disable-next-line import/no-named-as-default-member +i18n.init({ + lng: getLang(), + fallbackLng: 'en', + debug: process.env.NODE_ENV === 'development', + interpolation: { + escapeValue: false, + }, + // add locales here to enable them in the UI + resources: { + en: { + translation: en + }, + fr: { + translation: fr + } + } +}); + +export default i18n; \ No newline at end of file diff --git a/ui/i18n/entity_mapping.ts b/ui/i18n/entity_mapping.ts new file mode 100644 index 0000000000..a79429367f --- /dev/null +++ b/ui/i18n/entity_mapping.ts @@ -0,0 +1,161 @@ +import { LaunchStatus } from '../core/launched_sims'; +import { Class, PseudoStat, Spec, Stat } from '../core/proto/common'; +import i18n from './config'; + +export const statI18nKeys: Record = { + [Stat.StatStrength]: 'strength', + [Stat.StatAgility]: 'agility', + [Stat.StatStamina]: 'stamina', + [Stat.StatIntellect]: 'intellect', + [Stat.StatSpirit]: 'spirit', + [Stat.StatHitRating]: 'spell_hit', + [Stat.StatCritRating]: 'spell_crit', + [Stat.StatHasteRating]: 'spell_haste', + [Stat.StatExpertiseRating]: 'expertise', + [Stat.StatDodgeRating]: 'dodge', + [Stat.StatParryRating]: 'parry', + [Stat.StatMasteryRating]: 'mastery', + [Stat.StatAttackPower]: 'attack_power', + [Stat.StatRangedAttackPower]: 'ranged_attack_power', + [Stat.StatSpellPower]: 'spell_power', + [Stat.StatPvpResilienceRating]: 'pvp_resilience', + [Stat.StatPvpPowerRating]: 'pvp_power', + [Stat.StatArmor]: 'armor', + [Stat.StatBonusArmor]: 'bonus_armor', + [Stat.StatHealth]: 'health', + [Stat.StatMana]: 'mana', + [Stat.StatMP5]: 'mp5', +}; + +export const pseudoStatI18nKeys: Record = { + [PseudoStat.PseudoStatMainHandDps]: 'main_hand_dps', + [PseudoStat.PseudoStatOffHandDps]: 'off_hand_dps', + [PseudoStat.PseudoStatRangedDps]: 'ranged_dps', + [PseudoStat.PseudoStatDodgePercent]: 'dodge', + [PseudoStat.PseudoStatParryPercent]: 'parry', + [PseudoStat.PseudoStatBlockPercent]: 'block', + [PseudoStat.PseudoStatMeleeSpeedMultiplier]: 'melee_speed_multiplier', + [PseudoStat.PseudoStatRangedSpeedMultiplier]: 'ranged_speed_multiplier', + [PseudoStat.PseudoStatCastSpeedMultiplier]: 'cast_speed_multiplier', + [PseudoStat.PseudoStatMeleeHastePercent]: 'melee_haste', + [PseudoStat.PseudoStatRangedHastePercent]: 'ranged_haste', + [PseudoStat.PseudoStatSpellHastePercent]: 'spell_haste', + [PseudoStat.PseudoStatPhysicalHitPercent]: 'melee_hit', + [PseudoStat.PseudoStatSpellHitPercent]: 'spell_hit', + [PseudoStat.PseudoStatPhysicalCritPercent]: 'melee_crit', + [PseudoStat.PseudoStatSpellCritPercent]: 'spell_crit', +}; + +export const classI18nKeys: Record = { + [Class.ClassUnknown]: 'unknown', + [Class.ClassWarrior]: 'warrior', + [Class.ClassPaladin]: 'paladin', + [Class.ClassHunter]: 'hunter', + [Class.ClassRogue]: 'rogue', + [Class.ClassPriest]: 'priest', + [Class.ClassDeathKnight]: 'death_knight', + [Class.ClassShaman]: 'shaman', + [Class.ClassMage]: 'mage', + [Class.ClassWarlock]: 'warlock', + [Class.ClassMonk]: 'monk', + [Class.ClassDruid]: 'druid', + [Class.ClassExtra1]: 'extra1', + [Class.ClassExtra2]: 'extra2', + [Class.ClassExtra3]: 'extra3', + [Class.ClassExtra4]: 'extra4', + [Class.ClassExtra5]: 'extra5', + [Class.ClassExtra6]: 'extra6', +}; + +export const specI18nKeys: Record = { + [Spec.SpecUnknown]: 'unknown', + // Death Knight + [Spec.SpecBloodDeathKnight]: 'blood', + [Spec.SpecFrostDeathKnight]: 'frost', + [Spec.SpecUnholyDeathKnight]: 'unholy', + // Druid + [Spec.SpecBalanceDruid]: 'balance', + [Spec.SpecFeralDruid]: 'feral', + [Spec.SpecGuardianDruid]: 'guardian', + [Spec.SpecRestorationDruid]: 'restoration', + // Hunter + [Spec.SpecBeastMasteryHunter]: 'beast_mastery', + [Spec.SpecMarksmanshipHunter]: 'marksmanship', + [Spec.SpecSurvivalHunter]: 'survival', + // Mage + [Spec.SpecArcaneMage]: 'arcane', + [Spec.SpecFireMage]: 'fire', + [Spec.SpecFrostMage]: 'frost', + // Monk + [Spec.SpecBrewmasterMonk]: 'brewmaster', + [Spec.SpecMistweaverMonk]: 'mistweaver', + [Spec.SpecWindwalkerMonk]: 'windwalker', + // Paladin + [Spec.SpecHolyPaladin]: 'holy', + [Spec.SpecProtectionPaladin]: 'protection', + [Spec.SpecRetributionPaladin]: 'retribution', + // Priest + [Spec.SpecDisciplinePriest]: 'discipline', + [Spec.SpecHolyPriest]: 'holy', + [Spec.SpecShadowPriest]: 'shadow', + // Rogue + [Spec.SpecAssassinationRogue]: 'assassination', + [Spec.SpecCombatRogue]: 'combat', + [Spec.SpecSubtletyRogue]: 'subtlety', + // Shaman + [Spec.SpecElementalShaman]: 'elemental', + [Spec.SpecEnhancementShaman]: 'enhancement', + [Spec.SpecRestorationShaman]: 'restoration', + // Warlock + [Spec.SpecAfflictionWarlock]: 'affliction', + [Spec.SpecDemonologyWarlock]: 'demonology', + [Spec.SpecDestructionWarlock]: 'destruction', + // Warrior + [Spec.SpecArmsWarrior]: 'arms', + [Spec.SpecFuryWarrior]: 'fury', + [Spec.SpecProtectionWarrior]: 'protection', +}; + +export const statusI18nKeys: Record = { + [LaunchStatus.Unlaunched]: 'unlaunched', + [LaunchStatus.Alpha]: 'alpha', + [LaunchStatus.Beta]: 'beta', + [LaunchStatus.Launched]: 'launched', +}; + +export const translateStat = (stat: Stat): string => { + const key = statI18nKeys[stat] || Stat[stat].toLowerCase(); + return i18n.t(`common.stats.${key}`); +}; + +export const translatePseudoStat = (pseudoStat: PseudoStat): string => { + const key = pseudoStatI18nKeys[pseudoStat] || PseudoStat[pseudoStat].toLowerCase(); + return i18n.t(`common.stats.${key}`); +}; + +export const translateClassEnum = (classID: Class): string => { + const key = getClassI18nKey(classID); + return i18n.t(`common.classes.${key}`); +}; + +export const translateSpecEnum = (specID: Spec): string => { + const key = getSpecI18nKey(specID); + return i18n.t(`common.specs.${key}`); +}; + +export const translateStatus = (status: LaunchStatus): string => { + const key = getStatusI18nKey(status); + return i18n.t(`common.status.${key}`); +}; + +export function getClassI18nKey(classID: Class): string { + return classI18nKeys[classID] || Class[classID].toLowerCase(); +} + +export function getSpecI18nKey(specID: Spec): string { + return specI18nKeys[specID] || Spec[specID].toLowerCase(); +} + +export function getStatusI18nKey(status: LaunchStatus): string { + return statusI18nKeys[status] || LaunchStatus[status].toLowerCase(); +} diff --git a/ui/i18n/locale_service.ts b/ui/i18n/locale_service.ts new file mode 100644 index 0000000000..d225be136d --- /dev/null +++ b/ui/i18n/locale_service.ts @@ -0,0 +1,44 @@ +// Locale service for WoWSims +// Single source of truth for language settings + +const STORAGE_KEY = 'lang'; + +export const supportedLanguages: Record = { + 'en': 'English', + 'cn': '简体中文', + 'de': 'Deutsch', + 'es': 'Español', + 'fr': 'Français', + 'it': 'Italiano', + 'ko': '한국어', + 'pt': 'Português Brasileiro', + 'ru': 'Русский', +}; + +export const getLang = (): string => { + const storedLang = localStorage.getItem(STORAGE_KEY); + if (storedLang && storedLang in supportedLanguages) { + return storedLang; + } + return setLang('en'); +}; + +export const setLang = (lang: string): string => { + if (lang in supportedLanguages) { + localStorage.setItem(STORAGE_KEY, lang); + document.documentElement.lang = lang; + if (window.i18next) { + window.i18next.changeLanguage(lang); + } + } + return lang; +}; + +// Add TypeScript interface for i18next on window +declare global { + interface Window { + i18next: { + changeLanguage: (lang: string) => Promise; + }; + } +} diff --git a/ui/i18n/localization.tsx b/ui/i18n/localization.tsx new file mode 100644 index 0000000000..fbe0de611d --- /dev/null +++ b/ui/i18n/localization.tsx @@ -0,0 +1,220 @@ +import { PlayerClass } from '../core/player_class'; +import { PlayerSpec } from '../core/player_spec'; +import { PseudoStat, Stat } from '../core/proto/common'; +import i18n from './config'; +import { getClassI18nKey, getSpecI18nKey, pseudoStatI18nKeys, statI18nKeys } from './entity_mapping'; +import { getLang, setLang, supportedLanguages } from './locale_service'; + +/** + * Entity translation functions + */ + +export const translateStat = (stat: Stat): string => { + const key = statI18nKeys[stat] || Stat[stat].toLowerCase(); + return i18n.t(`common.stats.${key}`); +}; + +export const translatePseudoStat = (pseudoStat: PseudoStat): string => { + const key = pseudoStatI18nKeys[pseudoStat] || PseudoStat[pseudoStat].toLowerCase(); + return i18n.t(`common.stats.${key}`); +}; + +export const translateClass = (className: string): string => { + const normalizedClassName = className.toLowerCase().replace(/_/g, ''); + const i18nKey = normalizedClassName === 'deathknight' ? 'death_knight' : normalizedClassName; + return i18n.t(`common.classes.${i18nKey}`); +}; + +export const translateSpec = (className: string, specName: string): string => { + const normalizedClassName = className.toLowerCase().replace(/_/g, ''); + const classKey = normalizedClassName === 'deathknight' ? 'death_knight' : normalizedClassName; + const specKey = specName.toLowerCase(); + return i18n.t(`common.specs.${classKey}.${specKey}`); +}; + +export const translatePlayerClass = (playerClass: PlayerClass): string => { + const classKey = getClassI18nKey(playerClass.classID); + return translateClass(classKey); +}; + +export const translatePlayerSpec = (playerSpec: PlayerSpec): string => { + const classKey = getClassI18nKey(playerSpec.classID); + const specKey = getSpecI18nKey(playerSpec.specID); + return translateSpec(classKey, specKey); +}; + +/** + * Component Translation Helpers + */ + +export const extractClassAndSpecFromLink = (link: HTMLAnchorElement): { className?: string; specName?: string } => { + const parts = link.pathname.split('/').filter(Boolean); + if (parts.length >= 2) { + return { + className: parts[1], + specName: parts[2] + }; + } + return {}; +}; + +export const extractClassAndSpecFromDataAttributes = (): { className: string; specName: string } | null => { + const titleElement = document.querySelector('title'); + if (titleElement) { + const className = titleElement.getAttribute('data-class'); + const specName = titleElement.getAttribute('data-spec'); + if (className && specName) { + return { className, specName }; + } + } + + const metaDescription = document.querySelector('meta[name="description"]') as HTMLMetaElement; + if (metaDescription) { + const className = metaDescription.getAttribute('data-class'); + const specName = metaDescription.getAttribute('data-spec'); + if (className && specName) { + return { className, specName }; + } + } + return null; +}; + +export const updateLanguageDropdown = (): void => { + const dropdownMenu = document.querySelector('.dropdown-menu[aria-labelledby="languageDropdown"]'); + if (!dropdownMenu) return; + + const currentLang = getLang(); + dropdownMenu.innerHTML = ''; + + Object.entries(supportedLanguages).forEach(([code, name]) => { + const handleClick = (e: Event) => { + e.preventDefault(); + setLang(code); + window.location.reload(); + }; + + const languageItem = ( +
    • + + {name} + +
    • + ); + + dropdownMenu.appendChild(languageItem); + }); +}; + +export const updateDataI18nElements = (): void => { + document.querySelectorAll('[data-i18n]').forEach(element => { + const key = element.getAttribute('data-i18n'); + if (key) { + element.textContent = i18n.t(key); + } + }); +}; + +export const updateSimPageMetadata = (): void => { + const classSpecInfo = extractClassAndSpecFromDataAttributes(); + if (!classSpecInfo) return; + + const { className, specName } = classSpecInfo; + + const translatedClass = translateClass(className); + const translatedSpec = translateSpec(className, specName); + + const titleElement = document.querySelector('title'); + if (titleElement) { + const titleTemplate = i18n.t('sim.title'); + titleElement.textContent = titleTemplate + .replace('{class}', translatedClass) + .replace('{spec}', translatedSpec); + } + + const metaDescription = document.querySelector('meta[name="description"]') as HTMLMetaElement; + if (metaDescription) { + const descriptionTemplate = i18n.t('sim.description'); + metaDescription.content = descriptionTemplate + .replace('{class}', translatedClass) + .replace('{spec}', translatedSpec); + } +}; + +export const updateSimLinks = (): void => { + document.querySelectorAll('.sim-link-content').forEach(content => { + const classLabel = content.querySelector('.sim-link-label'); + const specTitle = content.querySelector('.sim-link-title'); + const link = content.closest('a'); + + if (classLabel && specTitle && link instanceof HTMLAnchorElement) { + const info = extractClassAndSpecFromLink(link); + if (info && info.className && info.specName) { + classLabel.textContent = translateClass(info.className); + specTitle.textContent = translateSpec(info.className, info.specName); + } + } else if (specTitle && link instanceof HTMLAnchorElement) { + const info = extractClassAndSpecFromLink(link); + if (info && info.className) { + specTitle.textContent = translateClass(info.className); + } + } + }); +}; + +/** + * Localization Initialization + */ + +export interface LocalizationOptions { + updateSimMetadata?: boolean; + updateSimLinks?: boolean; + updateLanguageDropdown?: boolean; +} + +export const updateTranslations = (options: LocalizationOptions = {}): void => { + document.documentElement.lang = getLang(); + updateDataI18nElements(); + + if (options.updateSimMetadata) { + updateSimPageMetadata(); + } + + if (options.updateSimLinks) { + updateSimLinks(); + } + + if (options.updateLanguageDropdown) { + updateLanguageDropdown(); + } +}; + +export const initLocalization = (options?: LocalizationOptions): void => { + const finalOptions = options || ( + document.querySelector('title[data-class]') || document.querySelector('meta[data-class]') + ? { updateSimMetadata: true } + : { updateSimLinks: true, updateLanguageDropdown: true } + ); + + const initialize = () => { + if (!i18n.isInitialized) { + i18n.init(); + } + + i18n.on('languageChanged', () => { + updateTranslations(finalOptions); + }); + + updateTranslations(finalOptions); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initialize); + } else { + initialize(); + } +}; diff --git a/ui/index.html b/ui/index.html index 04cb4b487e..7f6fbeff4a 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1,12 +1,12 @@ - + - WoWSims - Mists of Pandaria + Welcome to WoWSims MOP - + @@ -22,17 +22,17 @@
      -

      WoWSims

      -

      Mists of Pandaria

      +

      +

      -
      @@ -53,17 +60,14 @@

      Mists of Pandaria

      -

      - Welcome to WoWSims - Mists of Pandaria! This is a community-driven project to provide class and raid simulations for - World of Warcraft® Mists of Pandaria Classic together with the leading theorycrafters and class representatives. -

      +

      -
      +
      + diff --git a/ui/index_template.html b/ui/index_template.html index 01c2c27e46..60db5a2281 100644 --- a/ui/index_template.html +++ b/ui/index_template.html @@ -1,12 +1,12 @@ - + - Mists of Pandaria @@SPEC@@ @@CLASS@@ simulator + - + @@ -41,5 +41,9 @@ const whTooltips = { colorLinks: true }; + diff --git a/ui/raid/raid_sim_ui.tsx b/ui/raid/raid_sim_ui.tsx index 5e1c0bae41..595f070ca9 100644 --- a/ui/raid/raid_sim_ui.tsx +++ b/ui/raid/raid_sim_ui.tsx @@ -12,6 +12,7 @@ import { getPlayerSpecFromPlayer, makeDefaultBlessings } from '../core/proto_uti import { Sim } from '../core/sim'; import { SimUI } from '../core/sim_ui'; import { EventID, TypedEvent } from '../core/typed_event'; +import i18n from '../i18n/config'; import { BlessingsPicker } from './blessings_picker'; import { RaidJsonExporter } from './components/exporters'; import { RaidJsonImporter, RaidWCLImporter } from './components/importers'; @@ -117,7 +118,7 @@ export class RaidSimUI extends SimUI { private addDetailedResultsTab() { const detailedResults = (
      ) as HTMLElement; - this.addTab('Results', 'detailed-results-tab', detailedResults); + this.addTab(i18n.t('results.title'), 'detailed-results-tab', detailedResults); new EmbeddedDetailedResults(detailedResults, this, this.raidSimResultsManager!); } diff --git a/vite.config.mts b/vite.config.mts index b7d2c4ba4f..50bd9525f3 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -22,21 +22,31 @@ function serveExternalAssets() { configureServer(server) { server.middlewares.use((req, res, next) => { const url = req.url!; + const urlWithoutQuery = url.split('?')[0]; + const isImport = url.includes('?import'); - if (Object.keys(workerMappings).includes(url)) { - const targetPath = workerMappings[url as keyof typeof workerMappings]; + if (Object.keys(workerMappings).includes(urlWithoutQuery)) { + const targetPath = workerMappings[urlWithoutQuery as keyof typeof workerMappings]; const assetsPath = path.resolve(__dirname, './dist/mop'); const requestedPath = path.join(assetsPath, targetPath.replace('/mop/', '')); - serveFile(res, requestedPath); + + serveFile(res, requestedPath, isImport); return; } if (url.includes('/mop/assets')) { const assetsPath = path.resolve(__dirname, './assets'); - const assetRelativePath = url.split('/mop/assets')[1]; + const assetRelativePath = urlWithoutQuery.split('/mop/assets')[1]; const requestedPath = path.join(assetsPath, assetRelativePath); - serveFile(res, requestedPath); + serveFile(res, requestedPath, isImport); + return; + } else if (url.includes('/mop/locales')) { + const localesPath = path.resolve(__dirname, './assets/locales'); + const localeRelativePath = urlWithoutQuery.split('/mop/locales')[1]; + const requestedPath = path.join(localesPath, localeRelativePath); + + serveFile(res, requestedPath, isImport); return; } else { next(); @@ -46,11 +56,18 @@ function serveExternalAssets() { } satisfies PluginOption; } -function serveFile(res: ServerResponse, filePath: string) { +function serveFile(res: ServerResponse, filePath: string, isImport = false) { if (fs.existsSync(filePath)) { - const contentType = determineContentType(filePath); + const contentType = determineContentType(filePath, isImport); res.writeHead(200, { 'Content-Type': contentType }); - fs.createReadStream(filePath).pipe(res); + + if (isImport && path.extname(filePath).toLowerCase() === '.json') { + // For JSON imports, serve as ES module + const jsonContent = fs.readFileSync(filePath, 'utf-8'); + res.end(`export default ${jsonContent}`); + } else { + fs.createReadStream(filePath).pipe(res); + } } else { console.log('Not found on filesystem: ', filePath); res.writeHead(404, { 'Content-Type': 'text/plain' }); @@ -58,7 +75,7 @@ function serveFile(res: ServerResponse, filePath: string) { } } -function determineContentType(filePath: string) { +function determineContentType(filePath: string, isImport = false) { const extension = path.extname(filePath).toLowerCase(); switch (extension) { case '.jpg': @@ -76,15 +93,37 @@ function determineContentType(filePath: string) { case '.woff2': return 'font/woff2'; case '.json': - return 'application/json'; + return isImport ? 'text/javascript' : 'application/json'; case '.wasm': - return 'application/wasm'; // Adding MIME type for WebAssembly files - // Add more cases as needed + return 'application/wasm'; default: return 'application/octet-stream'; } } +function copyLocales() { + return { + name: 'copy-locales', + buildStart() { + // add locales here to enable them in the UI + const locales = [ + 'en.json', + 'fr.json' + ]; + const srcDir = path.resolve(__dirname, 'assets/locales'); + const destDir = path.resolve(__dirname, 'dist/mop/assets/locales'); + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + locales.forEach(file => { + const src = path.join(srcDir, file); + const dest = path.join(destDir, file); + fs.copyFileSync(src, dest); + }); + }, + } satisfies PluginOption; +} + export const getBaseConfig = ({ command, mode }: ConfigEnv) => ({ base: '/mop/', @@ -103,6 +142,7 @@ export default defineConfig(({ command, mode }) => { ...baseConfig, plugins: [ serveExternalAssets(), + copyLocales(), checker({ root: path.resolve(__dirname, 'ui'), typescript: true,