diff --git a/.gitattributes b/.gitattributes
index d47f08155..b36af79af 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,4 @@
-* -diff -merge
+build/** -diff -merge
+*.zip -diff -merge
+*.nbt -diff -merge
+*.ogg -diff -merge
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
new file mode 100644
index 000000000..2a46077ed
--- /dev/null
+++ b/.github/workflows/checks.yml
@@ -0,0 +1,30 @@
+name: Checks
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ static:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.14"
+
+ - name: Install dependencies
+ run: pip install --upgrade stewbeet ruff pyright
+
+ - name: Lint
+ run: ruff check src tools
+
+ - name: Type check
+ run: pyright
+
+ - name: Architectural conventions
+ run: python tools/check_conventions.py
diff --git a/README.md b/README.md
index 2b85c46fa..b18e0893b 100644
--- a/README.md
+++ b/README.md
@@ -26,14 +26,15 @@ Documents (Google Sheet) :
- [Le pipeline de build](#le-pipeline-de-build)
- [Où se trouve quoi ?](#où-se-trouve-quoi-)
- [Ajouter un mode de jeu](#ajouter-un-mode-de-jeu)
- - [1. Créer le dossier](#1-créer-le-dossier)
- - [2. Écrire `main.py`](#2-écrire-mainpy)
+ - [1. Générer le squelette](#1-générer-le-squelette)
+ - [2. Remplir `main.py`](#2-remplir-mainpy)
- [3. Déclarer le mode dans la liste de vote](#3-déclarer-le-mode-dans-la-liste-de-vote)
- [4. Tester en jeu](#4-tester-en-jeu)
- [5. Ressources supplémentaires (optionnel)](#5-ressources-supplémentaires-optionnel)
- [Anatomie d'un mode](#anatomie-dun-mode)
- [Ajouter autre chose](#ajouter-autre-chose)
- [Conventions de code](#conventions-de-code)
+ - [Garde-fous](#garde-fous)
- [Workflow Git](#workflow-git)
@@ -80,15 +81,17 @@ Pour retrouver le code d'une commande in-game, grep son texte dans `src/` : le c
Switch/
├── ⚙️ beet.yml # Config du projet et du pipeline de build
├── 🚀 upload.py # Publication d'une release GitHub
+├── 🧰 tools/ # Scaffolding et garde-fous (voir plus bas)
├── 🐍 src/ # TOUT le code source
│ ├── setup_definitions.py # Étape 1 : items, blocs, matériaux, disques
│ ├── link.py # Étape 2 : appelle tous les générateurs
+│ ├── validation.py # Contrôles de cohérence, échoue le build
│ ├── 📦 database/ # Items et comportements de blocs
│ ├── 🎨 resource_pack/ # Langues, sounds.json, shaders, textures GUI, fonts
│ └── 📂 datapack/
│ ├── main.py # Définitions brutes du datapack
│ ├── definitions/ # Advancements, dimensions, loot tables, prédicats, tags...
-│ ├── 🎮 modes/ # Un dossier par mini-jeu
+│ ├── 🎮 modes/ # Un dossier par mini-jeu, + spec/catalogue/emit
│ ├── 🧠 engine/ # Vote, démarrage, arrêt, signaux vers les modes
│ ├── 🧍 player/ # Layout d'inventaire, practice, jump timer
│ ├── 🗺️ maps/ # Chargement des maps, checkpoints, cycles de spawn
@@ -131,12 +134,12 @@ Le reste vient de plugins StewBeet : headers, constantes de scoreboard, dépenda
| Je veux modifier... | Fichier |
|-------------------------------------------------------------------------|---------------------------------------------------------------------------------------|
-| 🗳️ La liste des mini-jeux votables, descriptions, temps estimé, auteurs | `src/datapack/modes/definitions.py` (`MODES`) |
-| 🧩 Les groupes de vote (variantes sous une même entrée) | `src/datapack/modes/definitions.py` (`GROUPS_INFO`) |
+| 🗳️ La liste des mini-jeux votables, descriptions, temps estimé, auteurs | `src/datapack/modes/catalogue.py` (`MODES`) |
+| 🧩 Les groupes de vote (variantes sous une même entrée) | `src/datapack/modes/catalogue.py` (`GROUPS`) |
| 🎮 La logique d'un mini-jeu | `src/datapack/modes//main.py` |
| 🌐 Les messages FR/EN d'un mini-jeu | `src/datapack/modes//translations.py` |
| 📜 Les advancements, prédicats, loot tables, structures d'un mini-jeu | `src/datapack/modes//resources.py` |
-| 🔁 Un helper partagé entre plusieurs modes | `src/datapack/modes/common.py` ou `src/datapack/modes/_common/main.py` |
+| 🔁 Un helper partagé entre plusieurs modes | `src/datapack/modes/emit.py` ou `src/datapack/modes/_common/main.py` |
| 🧠 Le vote, le lancement, l'arrêt d'une partie, les signaux | `src/datapack/engine/main.py` |
| 🌍 Les maps de jeu (dimensions, régénération, zones) | `src/datapack/survival_maps/definitions.py` |
| 🏁 Les checkpoints de course et les cycles de spawn | `src/datapack/maps/main.py` |
@@ -153,24 +156,30 @@ Le reste vient de plugins StewBeet : headers, constantes de scoreboard, dépenda
## Ajouter un mode de jeu
-### 1. Créer le dossier
+### 1. Générer le squelette
-La découverte est automatique, aucun import à ajouter ailleurs.
+```bash
+python tools/new_mode.py mon_mode
+```
+
+Le mode obtenu build et se lance en jeu immédiatement. La découverte est automatique : aucun import à ajouter ailleurs.
```bash
src/datapack/modes/mon_mode/
├── __init__.py # vide
├── main.py # write_mode()
-└── translations.py # write_translations(), optionnel mais recommandé
+└── translations.py # write_translations()
```
-### 2. Écrire `main.py`
+### 2. Remplir `main.py`
+
+Le fichier généré ressemble déjà à ceci, avec les six hooks branchés. Il ne reste qu'à écrire les mécaniques du jeu.
```python
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
@@ -243,10 +252,11 @@ Dans `src/datapack/modes/definitions.py`, ajoutez une entrée à `MODES` :
| Clé / comportement | Détail |
|-----------------------|----------------------------------------------------------------------------------------------|
| `id` | **Exactement** le nom du dossier |
-| `max_players` | `-1` signifie qu'il n'y a pas de limite |
-| `group` | Clé optionnelle : les jeux d'un même groupe forment une entrée de vote, puis un second vote départage (voir `GROUPS_INFO`) |
+| `max_players` | `UNLIMITED` quand le mode n'a pas de plafond |
+| `group` | Optionnel : les jeux d'un même groupe forment une entrée de vote, puis un second vote départage (voir `GROUPS`) |
| Lore, index, pop-ups | Générés automatiquement à partir de cette entrée |
| Entrée commentée | Le mode est **quand même généré** : testable via `_force_start` sans polluer le vote |
+| Erreur de saisie | Le build échoue avec un message qui nomme le problème et suggère la valeur la plus proche |
### 4. Tester en jeu
@@ -269,13 +279,13 @@ Advancements, prédicats, loot tables, item modifiers, tags ou structures vont d
| `translations.py` | `write_translations()` : messages FR/EN dans `/translations/*` | `write_mode()` |
| `resources.py` | `write_resources()` : advancements, prédicats, loot tables, tags, structures | automatique |
| `kits.py` | Kits et classes du mode, construits avec `Kit` et `KitItem` | `write_mode()` |
-| `shop.py` | Dictionnaire d'upgrades de la boutique du mode | `src/datapack/shop/shared_memory.py` |
+| `shop.py` | Constante `SHOP` : les upgrades vendues par le mode | automatique |
| `structures/` | Fichiers `.nbt`, enregistrés via `register_structures()` | `resources.py` |
| `sounds/` | Fichiers `.ogg` propres au mode, via `register_sounds()` | `resources.py` |
- **`_common/`** : fonctions partagées écrites dans `switch:modes/_common/*` (mort en spectateur, fin de partie, barre d'XP, kits communs).
- **`_coupdetat/`** : pseudo-mode utilisé par le moteur, pas un mini-jeu votable.
-- Les helpers Python partagés vivent dans `src/datapack/modes/common.py` : `write_modes_calls`, `write_server_announce`, `write_time_xp_bar`, `write_no_drop`, `register_structures`, `register_sounds`.
+- Les helpers Python partagés vivent dans `src/datapack/modes/emit.py` : `write_modes_calls`, `write_server_announce`, `write_time_xp_bar`, `write_no_drop`, `register_structures`, `register_sounds`.
- Le namespace n'est jamais écrit en dur : utilisez toujours `ns: str = Mem.ctx.project_id`.
@@ -285,7 +295,7 @@ Advancements, prédicats, loot tables, item modifiers, tags ou structures vont d
- **Une map** : un `clone_survival(...)` ou `fill_survival(...)` dans `src/datapack/survival_maps/definitions.py` (coordonnées, id, nom, auteurs, `view` de la cinématique). Elle devient utilisable dans le `maps:[...]` d'un `choose_map_for`.
- **Un item ou un bloc** : `Item(...)` ou `Block(...)` dans `src/database/misc_items.py`. Texture trouvée automatiquement si un `.png` du même nom existe sous `assets/textures/` ; modèle, loot table et recettes sont générés.
- **Un son** : le `.ogg` dans `assets/sounds/`, ou dans le `sounds/` du mode s'il lui est propre. Voir `assets/compress_ogg.py`, `force_mono.py` et `mp3_to_ogg.py`.
-- **Une boutique** : un `shop.py` sur le modèle de `spleef/shop.py`, référencé dans `SHOPS` (`src/datapack/shop/shared_memory.py`).
+- **Une boutique** : un `shop.py` exposant une constante `SHOP`, sur le modèle de `spleef/shop.py`. Le registre la récupère tout seul ; son rang dans la boutique vient de `SHOP_ORDER` (`src/datapack/shop/shared_memory.py`).
@@ -296,11 +306,31 @@ Advancements, prédicats, loot tables, item modifiers, tags ou structures vont d
- Code auto-explicatif : peu de commentaires, les vraies explications vont dans les docstrings.
- Préférez le déclaratif et le paramétré à la duplication. Le CI lance [jscpd](https://github.com/kucherenko/jscpd) sur `src/` pour traquer le copier-coller.
- Un fichier qui dépasse environ 300 lignes devient un sous-module. **Exception assumée : le `main.py` d'un mode de jeu.** Toute la logique du mode reste au même endroit, ce qui rend le Ctrl+F immédiat et évite d'avoir à deviner dans quel fichier se trouve une mécanique. `build_battle/main.py` fait 935 lignes, et c'est très bien ainsi.
-- Lint : `ruff check src --fix`.
+- Lint : `ruff check src tools --fix` (la config vit dans `ruff.toml`).
- Perfs : le serveur tourne à 20 tps avec beaucoup de joueurs. Évitez les `@e` non filtrés dans les `tick`, préférez tags et scores.
+## Garde-fous
+
+Quatre outils, tous lançables à la main. Les trois premiers tournent aussi en CI.
+
+| Commande | Ce qu'elle garantit |
+|---|---|
+| `ruff check src tools` | Style et imports |
+| `pyright` | Typage strict, sans `Any` |
+| `python tools/check_conventions.py` | Taille des fichiers, pureté du modèle, aucun import descendant vers un mode nommé |
+| `python tools/check_output_drift.py` | **Le refactoring n'a rien changé** : rebuild puis `build/` identique à HEAD |
+| `python tools/report_merged_functions.py` | Aucune fonction n'est écrite par deux émetteurs sans que ce soit déclaré |
+
+Le plus important est `check_output_drift.py`. `write_function` **ajoute** à la suite par défaut, donc deux émetteurs visant le même chemin fusionnent silencieusement dans l'ordre d'appel. Après tout déplacement de code, un `build/` inchangé est la preuve que rien n'a bougé en jeu.
+
+Deux règles s'appuient sur des listes explicites qui ne doivent que diminuer :
+- `LONG_FILE_DEBT` dans `tools/check_conventions.py` : les fichiers qui dépassent encore 300 lignes.
+- `DECLARED_MERGES` dans `tools/report_merged_functions.py` : les fonctions volontairement construites par ajouts successifs.
+
+
+
## Workflow Git
- Une branche par fonctionnalité, puis Pull Request vers `main`.
diff --git a/pyrightconfig.json b/pyrightconfig.json
index eda844e61..e9d4570f1 100644
--- a/pyrightconfig.json
+++ b/pyrightconfig.json
@@ -1 +1 @@
-{ "typeCheckingMode": "strict", "include": ["src"], "exclude": ["**/__pycache__"] }
+{ "typeCheckingMode": "strict", "include": ["src", "tools"], "exclude": ["**/__pycache__"] }
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
index 000000000..d91992e4e
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,15 @@
+exclude = [".git", ".ruff_cache", ".venv", ".vscode", "build", "dist", "*.pyi"]
+line-length = 200
+
+[lint]
+select = ["E", "W", "F", "I", "N", "UP", "B", "C4", "T20", "RUF"]
+ignore = [
+ "T201", # print() is how the tools talk to the developer
+ "W191", # indentation is tabs, alignment is spaces
+ "N803", # argument names follow the datapack vocabulary
+ "N806", # local constants stay uppercase when they mirror a generated name
+]
+
+[lint.isort]
+# Keeps the "name as name" re-exports of a package grouped in a single statement
+combine-as-imports = true
diff --git a/src/datapack/misc_links/starting_pop_ups.py b/src/datapack/misc_links/starting_pop_ups.py
index b3347812b..3c3d585b3 100644
--- a/src/datapack/misc_links/starting_pop_ups.py
+++ b/src/datapack/misc_links/starting_pop_ups.py
@@ -4,7 +4,8 @@
from stewbeet import Mem, set_json_encoder, write_function
from stouputils.typing import JsonDict
-from ..modes.definitions import LANG_PRETEXT, MODES
+from ..modes.catalogue import MODES
+from ..modes.definitions import LANG_PRETEXT
# Main function
@@ -16,18 +17,15 @@ def main() -> None:
# For each minigame,
for mode in MODES:
- mode_id: str = mode["id"]
- mode_name: str = mode["name_fr"] if lang == "fr" else mode.get(f"name_{lang}", mode["name_fr"])
-
# Prepare the title of the advancement
- title: list[JsonDict] = [{"text":"'","color":"aqua"},{"text":mode_name,"color":"yellow"}]
+ title: list[JsonDict] = [{"text":"'","color":"aqua"},{"text":mode.display_name(lang),"color":"yellow"}]
if lang == "fr":
title.append({"text":"' sélectionné !"})
elif lang == "en":
title.append({"text":"' selected!"})
# Write the advancement (called in data/switch/function/engine/signals/macro_start.mcfunction)
- Mem.ctx.data[ns].advancements[f"pop_ups/{mode_id}_{lang}"] = set_json_encoder(Advancement({
+ Mem.ctx.data[ns].advancements[f"pop_ups/{mode.id}_{lang}"] = set_json_encoder(Advancement({
"display": {
"icon": {"id": "minecraft:stone","components": {"minecraft:item_model": f"{ns}:letter"}},
"title": title,
@@ -46,7 +44,7 @@ def main() -> None:
}))
# Write the reward function that revoke the advancement
- write_function(f"{ns}:engine/pop_ups/schedule", f"advancement revoke @a only {ns}:pop_ups/{mode_id}_{lang}")
+ write_function(f"{ns}:engine/pop_ups/schedule", f"advancement revoke @a only {ns}:pop_ups/{mode.id}_{lang}")
# Write the function that revokes the advancement
write_function(f"{ns}:engine/pop_ups/revoke", f"schedule function {ns}:engine/pop_ups/schedule 1s")
diff --git a/src/datapack/modes/__init__.py b/src/datapack/modes/__init__.py
index a9bd22214..e5b492fd5 100644
--- a/src/datapack/modes/__init__.py
+++ b/src/datapack/modes/__init__.py
@@ -3,8 +3,14 @@
import importlib
from pathlib import Path
+from stouputils.typing import JsonDict
+
from .definitions import write_modes_load_file
+# Constants
+MODE_SHOPS: dict[str, dict[str, JsonDict]] = {}
+""" Shop of every mode declaring one, keyed by mode id and filled by generate_all_modes(). """
+
# Main function
def generate_all_modes():
@@ -29,3 +35,8 @@ def generate_all_modes():
resources = importlib.import_module(f".{entry.name}.resources", package=__package__)
if hasattr(resources, "write_resources"):
resources.write_resources()
+
+ # A mode may sell upgrades, declared as SHOP in a sibling shop.py
+ if (entry / "shop.py").is_file():
+ shop = importlib.import_module(f".{entry.name}.shop", package=__package__)
+ MODE_SHOPS[entry.name] = shop.SHOP
diff --git a/src/datapack/modes/_common/main.py b/src/datapack/modes/_common/main.py
index 61a97f3c4..67ffa1012 100644
--- a/src/datapack/modes/_common/main.py
+++ b/src/datapack/modes/_common/main.py
@@ -5,6 +5,7 @@
from ...kits import Kit, KitItem
+
def pvp_arena_kit() -> Kit:
""" The melee loadout shared by castagne and pvpswap (castagne adds a fishing rod of its own).
Built lazily: the axe's modifier id embeds the project namespace, unknown at import time. """
diff --git a/src/datapack/modes/_coupdetat/main.py b/src/datapack/modes/_coupdetat/main.py
index d9e4a3741..c288caba4 100644
--- a/src/datapack/modes/_coupdetat/main.py
+++ b/src/datapack/modes/_coupdetat/main.py
@@ -3,7 +3,7 @@
from stewbeet import Mem, write_function
from ...kits import Kit, KitItem
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/beat_the_kings/main.py b/src/datapack/modes/beat_the_kings/main.py
index 8bf0a1db6..c0cecb400 100644
--- a/src/datapack/modes/beat_the_kings/main.py
+++ b/src/datapack/modes/beat_the_kings/main.py
@@ -4,7 +4,7 @@
from stewbeet import Mem, write_function
from ...kits import Kit, KitItem
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/block_party/main.py b/src/datapack/modes/block_party/main.py
index 731120ef0..494b94b49 100644
--- a/src/datapack/modes/block_party/main.py
+++ b/src/datapack/modes/block_party/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls
+from ..emit import write_modes_calls
from .translations import write_translations
diff --git a/src/datapack/modes/block_party/translations.py b/src/datapack/modes/block_party/translations.py
index 251e13f82..748e47d63 100644
--- a/src/datapack/modes/block_party/translations.py
+++ b/src/datapack/modes/block_party/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/boat_race/main.py b/src/datapack/modes/boat_race/main.py
index 1e75e18c0..ea8497efc 100644
--- a/src/datapack/modes/boat_race/main.py
+++ b/src/datapack/modes/boat_race/main.py
@@ -2,7 +2,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/boat_race/translations.py b/src/datapack/modes/boat_race/translations.py
index b2b60b2af..c4cae1008 100644
--- a/src/datapack/modes/boat_race/translations.py
+++ b/src/datapack/modes/boat_race/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/bombardement/main.py b/src/datapack/modes/bombardement/main.py
index f87793139..b1aaaa515 100644
--- a/src/datapack/modes/bombardement/main.py
+++ b/src/datapack/modes/bombardement/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls
+from ..emit import write_modes_calls
from .translations import write_translations
diff --git a/src/datapack/modes/bombardement/translations.py b/src/datapack/modes/bombardement/translations.py
index 69ef87623..401aceb50 100644
--- a/src/datapack/modes/bombardement/translations.py
+++ b/src/datapack/modes/bombardement/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/build_battle/main.py b/src/datapack/modes/build_battle/main.py
index fe9ea0507..8acf22b58 100644
--- a/src/datapack/modes/build_battle/main.py
+++ b/src/datapack/modes/build_battle/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls
+from ..emit import write_modes_calls
from .translations import write_translations
diff --git a/src/datapack/modes/build_battle/resources.py b/src/datapack/modes/build_battle/resources.py
index fb501d93c..3f5dd51b4 100644
--- a/src/datapack/modes/build_battle/resources.py
+++ b/src/datapack/modes/build_battle/resources.py
@@ -4,7 +4,7 @@
from beet.contrib.worldgen import Dimension
from stewbeet.core import Mem, set_json_encoder
-from ..common import register_structures
+from ..emit import register_structures
def write_resources() -> None:
diff --git a/src/datapack/modes/capture_the_flag/kits.py b/src/datapack/modes/capture_the_flag/kits.py
index eef09b248..6fed57fc2 100644
--- a/src/datapack/modes/capture_the_flag/kits.py
+++ b/src/datapack/modes/capture_the_flag/kits.py
@@ -9,6 +9,7 @@
they are built lazily by classes() instead of living at module level: the stewbeet CLI imports
this module before Mem.ctx exists.
"""
+# ruff: noqa: E501
# Imports
from stewbeet import Mem
diff --git a/src/datapack/modes/capture_the_flag/main.py b/src/datapack/modes/capture_the_flag/main.py
index dbeb1bb55..a7254487d 100644
--- a/src/datapack/modes/capture_the_flag/main.py
+++ b/src/datapack/modes/capture_the_flag/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls
+from ..emit import write_modes_calls
from .kits import classes
from .translations import write_translations
diff --git a/src/datapack/modes/castagne/main.py b/src/datapack/modes/castagne/main.py
index 33dc6efb9..ca560980e 100644
--- a/src/datapack/modes/castagne/main.py
+++ b/src/datapack/modes/castagne/main.py
@@ -4,7 +4,7 @@
from ...kits import Kit, KitItem
from .._common.main import pvp_arena_kit
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/catalogue.py b/src/datapack/modes/catalogue.py
new file mode 100644
index 000000000..e13bfc7c5
--- /dev/null
+++ b/src/datapack/modes/catalogue.py
@@ -0,0 +1,490 @@
+""" The minigames and vote groups of the server, as one declarative table.
+
+A mode generates even when its entry is commented out here: only its presence in the vote depends
+on it. Kept as a single table on purpose, so one Ctrl+F finds any game.
+"""
+# Imports
+from .spec import UNLIMITED, GroupSpec, Localized, ModeSpec
+
+# Constants
+# Minigames
+MODES: tuple[ModeSpec, ...] = (
+ ModeSpec(
+ id="pitch_creep", name_fr="Pitch Creep", group="creepers",
+ min_players=1, max_players=UNLIMITED,
+ estimated_time="1m30s", inspiration="Pitchout/Épicube", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Survivez en repoussant les creepers", "dans une arène fermée."),
+ en=("Survive by pushing away creepers", "in a closed arena."),
+ ),
+ ),
+ ModeSpec(
+ id="laser_game", name_fr="Laser Game",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="2m30s", inspiration="The physical world", suggested_by="Vigo", developed_by="Stoupy",
+ description=Localized(
+ fr=(
+ "Affrontez l'équipe adverse grâce à vos fusils-laser et faites",
+ "le plus d'élimination possible pendant un temps imparti !",
+ ),
+ en=(
+ "Battle the enemy team with your laser rifles and get",
+ "as many takedowns as possible within the allotted time!",
+ ),
+ ),
+ ),
+ ModeSpec(
+ id="warden_escape", name_fr="Warden Escape",
+ min_players=1, max_players=UNLIMITED,
+ estimated_time="1m30s", inspiration="Minecraft 1.19", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Survivez aux wardens dans", "une arène fermée."),
+ en=("Survive the wardens in", "a closed arena."),
+ ),
+ ),
+ ModeSpec(
+ id="pitchout", name_fr="Pitchout",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="1-5 mins", inspiration="Épicube", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Poussez vos adversaires dans l'eau", "et soyez le dernier survivant !"),
+ en=("Push your opponents into the water", "and be the last survivor!"),
+ ),
+ ),
+ ModeSpec(
+ id="creeper_apocalypse", name_fr="Creeper Apocalypse", group="creepers",
+ min_players=1, max_players=UNLIMITED,
+ estimated_time="1m30s", inspiration="Expiration", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Survivez aux creepers qui se", "multiplient et explosent."),
+ en=("Survive the creepers that", "duplicate and explode."),
+ ),
+ ),
+ ModeSpec(
+ id="traitors_game", name_fr="Traitors Game",
+ min_players=4, max_players=UNLIMITED,
+ estimated_time="4-10 mins", inspiration="Taupe Gun UHC", suggested_by="Luxio", developed_by="Stoupy",
+ description=Localized(
+ fr=(
+ "Des traitres se cachent parmi les joueurs.",
+ "Innocents contre Traitres, quel camp va-t-il gagner ?",
+ ),
+ en=("Traitors are hinding among players.", "Innocents versus Traitors, which side will win?"),
+ ),
+ ),
+ # ModeSpec(
+ # id="boat_race", name_fr="Boat Race",
+ # min_players=1, max_players=UNLIMITED,
+ # estimated_time="3-8 mins", inspiration="Expiration", suggested_by="Stoupy", developed_by="LTHCTheMaster",
+ # description=Localized(
+ # fr=("Battez-vous pour atteindre la ligne d'arrivée", "en premier pour remporter la partie !"),
+ # en=("Race for reaching the finish line", "in first to claim the win!"),
+ # ),
+ # ),
+ ModeSpec(
+ id="spectres_game", name_fr="Spectres Game",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="4-10 mins", inspiration="Expiration", suggested_by="Luxio", developed_by="Stoupy",
+ description=Localized(
+ fr=(
+ "Deux camps : un avec que des joueurs visibles,",
+ "l'autre avec des spectres, quel camp va-t-il gagner ?",
+ ),
+ en=("Two sides: one with visible players,", "the other with spectres, which side will win?"),
+ ),
+ ),
+ ModeSpec(
+ id="race", name_fr="Race",
+ min_players=1, max_players=UNLIMITED,
+ estimated_time="3-6 mins", inspiration="Racing games", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Battez-vous pour atteindre la ligne d'arrivée", "en premier pour remporter la partie !"),
+ en=("Race for reaching the finish line", "in first to claim the win!"),
+ ),
+ ),
+ ModeSpec(
+ id="rush_the_point", name_fr="Rush The Point", group="ctpf",
+ min_players=8, max_players=UNLIMITED,
+ estimated_time="7-10 mins", inspiration="PluginLess", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Capturez les points centraux dans le vide", "avec vos classes spéciales."),
+ en=("Capture the central points in the void", "with your special classes."),
+ ),
+ ),
+ ModeSpec(
+ id="rush_the_flag", name_fr="Rush The Flag", group="ctpf",
+ min_players=8, max_players=UNLIMITED,
+ estimated_time="5-10 mins", inspiration="PluginLess", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Volez le drapeau ennemi", "avec vos classes spéciales."),
+ en=("Steal the enemy flag", "with your special classes."),
+ ),
+ ),
+ ModeSpec(
+ id="spleef", name_fr="Spleef",
+ min_players=1, max_players=UNLIMITED,
+ estimated_time="1-4 mins", inspiration="everywhere", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Creusez sous vos adversaires", "pour les faire tomber !"),
+ en=("Dig below your opponents", "to make them fall!"),
+ ),
+ ),
+ ModeSpec(
+ id="de_a_coudre", name_fr="Dé à Coudre", group="a_coudre",
+ min_players=1, max_players=UNLIMITED,
+ estimated_time="3-6 mins", inspiration="OneCube point system", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Affrontez les autres joueurs dans un saut,", "dans une piscine, iconique de Minecraft !"),
+ en=("Take on the other players in", "Minecraft's iconic pool jump!"),
+ ),
+ ),
+ ModeSpec(
+ id="feed_fast", name_fr="Feed Fast",
+ min_players=1, max_players=UNLIMITED,
+ estimated_time="60s", inspiration="Le diabète", suggested_by="Arobaze", developed_by="LTHCTheMaster",
+ description=Localized(
+ fr=("Mangez le plus de nourriture", "possible pour gagner !"),
+ en=("Eat the most food", "possible to win!"),
+ ),
+ ),
+ ModeSpec(
+ id="mlg_a_coudre", name_fr="MLG à Coudre", group="a_coudre",
+ min_players=1, max_players=8,
+ estimated_time="1-6 mins", inspiration="Dé à Coudre", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Sautez et utilisez votre seau d'eau", "pour survivre à la chute !"),
+ en=("Jump and use your water bucket", "to survive the fall!"),
+ ),
+ ),
+ ModeSpec(
+ id="protect_the_king", name_fr="Protect The King",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="1-4 mins", inspiration="Charlemagne", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Tuez le roi ennemi ou", "protégez le vôtre !"),
+ en=("Kill the enemy king or", "protect your own!"),
+ ),
+ ),
+ ModeSpec(
+ id="castagne", name_fr="Castagne RP",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="1-5 mins", inspiration="Domestic Violence", suggested_by="JustNoob", developed_by="Stoupy",
+ description=Localized(
+ fr=("Battez-vous au corps à corps", "et soyez le dernier survivant !"),
+ en=("Fight hand-to-hand combat", "and be the last survivor!"),
+ ),
+ ),
+ ModeSpec(
+ id="layers_2_teams", name_fr="Layers 2 Teams", group="layers",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="4-10 mins", inspiration="PenguinGames", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Équipez-vous rapidement et détruisez", "l'équipe adverse située de l'autre côté du mur !"),
+ en=("Gear up quickly and destroy the enemy team", "on the other side of the wall!"),
+ ),
+ ),
+ ModeSpec(
+ id="layers_4_teams", name_fr="Layers 4 Teams", group="layers",
+ min_players=4, max_players=UNLIMITED,
+ estimated_time="4-10 mins", inspiration="PenguinGames", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Course d'équipement entre", "4 équipes, éliminez vos ennemis !"),
+ en=("Equipment race between", "4 teams, eliminate your enemies!"),
+ ),
+ ),
+ ModeSpec(
+ id="thunder_spear", name_fr="Thunder Spear",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="2m30s", inspiration="SNK/Attack On Titan", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Utilisez votre équipement tridimensionnel", "et vos lances explosives !"),
+ en=("Use your three-dimensional maneuver gear", "and explosive spears!"),
+ ),
+ ),
+ ModeSpec(
+ id="tnt_run", name_fr="TNT Run",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="1-4 mins", inspiration="Grian (2013)", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Restez en mouvement, les blocs", "disparaissent sous vos pieds !"),
+ en=("Keep moving, blocks disappear", "under your feet!"),
+ ),
+ ),
+ ModeSpec(
+ id="sheepwars", name_fr="SheepWars",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="2-6 mins", inspiration="Épicube", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Lancez des moutons explosifs", "sur l'équipe ennemie !"),
+ en=("Launch explosive sheep", "at the enemy team!"),
+ ),
+ ),
+ ModeSpec(
+ id="block_party", name_fr="Block Party",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="1-5 mins", inspiration="HiveMC", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Quand la musique s'arrête,", "trouvez la bonne couleur !"),
+ en=("When the music stops,", "find the right color!"),
+ ),
+ ),
+ ModeSpec(
+ id="moutron", name_fr="Moutron",
+ min_players=2, max_players=16,
+ estimated_time="1-3 mins", inspiration="Épicube", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Montez un mouton qui laisse", "une traînée mortelle !"),
+ en=("Ride a sheep that leaves", "a deadly trail!"),
+ ),
+ ),
+ ModeSpec(
+ id="snowball_painter", name_fr="Snowball Painter",
+ min_players=2, max_players=45,
+ estimated_time="45s", inspiration="Party Games/Hypixel", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Peignez le plus de blocs possible", "avec vos boules de neige !"),
+ en=("Paint the most blocks possible", "with your snowballs!"),
+ ),
+ ),
+ ModeSpec(
+ id="gay_shooter", name_fr="Guy Shooter",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="1-5 mins", inspiration="a Survisland Event", suggested_by="GoldVision & scartvol", developed_by="Stoupy",
+ description=Localized(
+ fr=("Archers contre grimpeurs :", "empêchez-les d'atteindre le sommet !"),
+ en=("Archers versus climbers:", "prevent them from reaching the top!"),
+ ),
+ ),
+ ModeSpec(
+ id="build_battle", name_fr="Build Battle",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="5-7 mins", inspiration="Hypixel", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Construisez sur un thème", "et votez pour le meilleur build !"),
+ en=("Build on a theme and", "vote for the best build!"),
+ ),
+ ),
+ ModeSpec(
+ id="beat_the_kings", name_fr="Beat The Kings",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="3-5 mins", inspiration="Henry IV/Louis XVI", suggested_by="Luxio", developed_by="Luxio",
+ description=Localized(
+ fr=("Révolutionnaires contre rois :", "qui survivra à la bataille ?"),
+ en=("Revolutionaries versus kings:", "who will survive the battle?"),
+ ),
+ ),
+ ModeSpec(
+ id="coin_chaser", name_fr="Coin Chaser",
+ min_players=1, max_players=UNLIMITED,
+ estimated_time="1m30s", inspiration="Expiration", suggested_by="Ikinox", developed_by="Stoupy",
+ description=Localized(
+ fr=("Collectez le plus d'or possible", "avec vos élytras !"),
+ en=("Collect the most gold possible", "with your elytra!"),
+ ),
+ ),
+ ModeSpec(
+ id="murder_mystery", name_fr="Murder Mystery",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="1-4 mins", inspiration="Hypixel", suggested_by="GoldVision", developed_by="Stoupy",
+ description=Localized(
+ fr=("Un meurtrier, un détective,", "et des innocents dans une map pleine d'or !"),
+ en=("A murderer, a detective,", "and innocents in a map full of gold!"),
+ ),
+ ),
+ ModeSpec(
+ id="panic_chase", name_fr="Panic Chase",
+ min_players=4, max_players=UNLIMITED,
+ estimated_time="1-2 mins", inspiration="Le chat et la souris", suggested_by="Arti", developed_by="Stoupy",
+ description=Localized(
+ fr=("Survivez aux chasseurs dans", "une petite arène fermée !"),
+ en=("Survive the hunters in", "a small closed arena!"),
+ ),
+ ),
+ ModeSpec(
+ id="simultaneous_jump", name_fr="Simultaneous Jump", group="a_coudre",
+ min_players=1, max_players=UNLIMITED,
+ estimated_time="60s", inspiration="Dé à Coudre/MLG à Coudre", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Dé à coudre ou MLG à coudre,", "mais tout le monde saute en même temps !"),
+ en=("Pool jump or MLG water bucket,", "but everyone jumps at the same time!"),
+ ),
+ ),
+ ModeSpec(
+ id="bombardement", name_fr="Bombardment",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="1-2 mins", inspiration="Hypixel", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Esquivez les tirs de ce bâteau volant", "afin d'être le dernier survivant !"),
+ en=("Dodge the shots from this flying boat", "to be the last survivor!"),
+ ),
+ ),
+ ModeSpec(
+ id="shoot_da_sheep", name_fr="Shoot Da'Sheep",
+ min_players=1, max_players=UNLIMITED,
+ estimated_time="60s", inspiration="BrawlMC", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=(
+ "Des moutons tombent du ciel, certains donnent plus de points",
+ "que d'autres, ayez le plus de points à la fin !",
+ ),
+ en=(
+ "Sheeps are falling from the sky, some give more points",
+ "than others, get the most points at the end!",
+ ),
+ ),
+ ),
+ ModeSpec(
+ id="minigolf", name_fr="MiniGolf",
+ min_players=1, max_players=UNLIMITED,
+ estimated_time="1-4 mins", inspiration="The physical world", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Soyez celui qui réussi à faire tomber sa balle dans", "le trou avec le moins de coup possible !"),
+ en=("Be the one to drop your golf ball in", "the hole with the fewest strokes possible!"),
+ ),
+ ),
+ ModeSpec(
+ id="cigogne", name_fr="Cigogne",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="1-4 mins", inspiration="ShoupZ/JustNoob", suggested_by="GoldVision", developed_by="Stoupy",
+ description=Localized(
+ fr=("Combat de spectres puissants", "dans une salle fermée !"),
+ en=("Powerful spectres battle", "in a closed room!"),
+ ),
+ ),
+ ModeSpec(
+ id="infected", name_fr="Infected",
+ min_players=8, max_players=UNLIMITED,
+ estimated_time="5-10 mins", inspiration="ImagineYourCraft (2013)", suggested_by="the whole Earth", developed_by="Stoupy",
+ description=Localized(
+ fr=(
+ "Un infecté est choisi en début de partie et doit infecter",
+ "tous les humains qui doivent attendre les renforts !",
+ ),
+ en=(
+ "An infected person is chosen at the beginning of the game and",
+ "must infect all humans who must wait for rescue!",
+ ),
+ ),
+ ),
+ ModeSpec(
+ id="glassrunner", name_fr="GlassRunner", group="ctpf",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="8-10 mins", inspiration="Épicube", suggested_by="AirDox", developed_by="AirDox",
+ description=Localized(
+ fr=(
+ "Affrontez l'équipe adverse dans le ciel dans un",
+ "CaptureThePoint assaisonné de multiples particularités",
+ ),
+ en=(
+ "Take on the opposing team in the sky in a",
+ "CaptureThePoint seasoned with multiple special features",
+ ),
+ ),
+ ),
+ ModeSpec(
+ id="capture_the_flag", name_fr="Capture The Flag", group="ctpf",
+ min_players=10, max_players=UNLIMITED,
+ estimated_time="10-15 mins", inspiration="Aspiration", suggested_by="Oraclette", developed_by="Stoupy",
+ description=Localized(
+ fr=(
+ "Deux équipes doivent s'affronter pour capturer",
+ "le drapeau ennemi grâce à des",
+ "classes spécifiques différentes",
+ ),
+ en=("Two teams must compete to capture the enemy flag", "with the help of different specific classes"),
+ ),
+ ),
+ ModeSpec(
+ id="memory_mine", name_fr="Memory Mine",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="1-2 mins", inspiration="Pico Party", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Mémorisez la position des mines", "qui clignotent et disparaissent !"),
+ en=("Memorize the position of mines", "that blink and disappear!"),
+ ),
+ ),
+ ModeSpec(
+ id="fish_fight", name_fr="Fish Fight",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="2-4 mins", inspiration="Findus", suggested_by="Luxio", developed_by="Luxio",
+ description=Localized(
+ fr=("Éjectez vos adversaires", "avec votre poisson !"),
+ en=("Eject your opponents", "with your fish!"),
+ ),
+ ),
+ ModeSpec(
+ id="one_shot", name_fr="One Shot",
+ min_players=2, max_players=10,
+ estimated_time="2-4 mins", inspiration="OneShot", suggested_by="Luxio", developed_by="Luxio",
+ description=Localized(
+ fr=(
+ "Éliminez vos adversaires grâce à votre arc",
+ "surpuissant et votre épée pour remporter la victoire !",
+ ),
+ en=("Eliminate your opponents with your", "powerful bow and sword to claim victory!"),
+ ),
+ ),
+ ModeSpec(
+ id="pillars_of_fortune", name_fr="Pillars of Fortune",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="1-6 mins", inspiration="Cubecraft", suggested_by="TreekoZ", developed_by="Stoupy",
+ description=Localized(
+ fr=("Recevez des objets aléatoires", "sur de grands piliers !"),
+ en=("Receive random items", "on large pillars!"),
+ ),
+ ),
+ ModeSpec(
+ id="fireblast", name_fr="Fireblast",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="1-2 mins", inspiration="LifeCraft", suggested_by="Stoupy", developed_by="Stoupy",
+ description=Localized(
+ fr=("Éjectez tous les joueurs de la plateforme", "en utilisant des boules de feu explosives !"),
+ en=("Knock out every players from the platform", "by using explosive fireballs!"),
+ ),
+ ),
+ # ModeSpec(
+ # id="pvpswap", name_fr="PvPSwap",
+ # min_players=2, max_players=32,
+ # estimated_time="1-2 mins", inspiration="Épicube", suggested_by="Arobaze", developed_by="Arobaze",
+ # description=Localized(
+ # fr=("Éliminez les autres joueurs au travers", "des changements inopinés de positions !"),
+ # en=("Eliminate other players through", "sudden changes in positions!"),
+ # ),
+ # ),
+)
+
+# Vote groups: modes sharing a "group" show up as a single entry, and a second vote picks one
+GROUPS: tuple[GroupSpec, ...] = (
+ GroupSpec(
+ id="a_coudre", name_fr="Dé À Coudre & Co", estimated_time="1-6 mins",
+ description=Localized(
+ fr=("Sautez dans la piscine, avec ou sans seau d'eau,", "tous en même temps ou chacun son tour !"),
+ en=("Jump into the pool, with or without a water bucket,", "all at once or one at a time!"),
+ ),
+ ),
+ GroupSpec(
+ id="creepers", name_fr="Creepers", estimated_time="1m30s",
+ description=Localized(
+ fr=("Survivez aux creepers, en les repoussant", "ou en fuyant leurs explosions !"),
+ en=("Survive the creepers, by pushing them away", "or fleeing their explosions!"),
+ ),
+ ),
+ GroupSpec(
+ id="layers", name_fr="Layers", estimated_time="4-10 mins",
+ description=Localized(
+ fr=("Équipez-vous rapidement et détruisez", "les équipes adverses !"),
+ en=("Gear up quickly and destroy", "the enemy teams!"),
+ ),
+ ),
+ GroupSpec(
+ id="ctpf", name_fr="Capture the Point/Flag", estimated_time="5-15 mins",
+ description=Localized(
+ fr=("Affrontez l'équipe adverse : capturez des points,", "volez des drapeaux ou courez sur le verre !"),
+ en=("Face off against the enemy team: capture points,", "steal flags or run across the glass!"),
+ ),
+ ),
+)
+
+GROUPS_BY_ID: dict[str, GroupSpec] = {group.id: group for group in GROUPS}
+""" Lookup used when building the storage entry of a multi-mode group. """
+
diff --git a/src/datapack/modes/cigogne/main.py b/src/datapack/modes/cigogne/main.py
index c3090a30a..4128fa9e3 100644
--- a/src/datapack/modes/cigogne/main.py
+++ b/src/datapack/modes/cigogne/main.py
@@ -4,7 +4,7 @@
from stewbeet import Mem, write_function
from ...kits import Kit, KitItem
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/coin_chaser/main.py b/src/datapack/modes/coin_chaser/main.py
index 0a3d6add4..a23e8d6d6 100644
--- a/src/datapack/modes/coin_chaser/main.py
+++ b/src/datapack/modes/coin_chaser/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_no_drop, write_time_xp_bar
+from ..emit import write_modes_calls, write_no_drop, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/coin_chaser/translations.py b/src/datapack/modes/coin_chaser/translations.py
index 17a3700df..ab942e9ac 100644
--- a/src/datapack/modes/coin_chaser/translations.py
+++ b/src/datapack/modes/coin_chaser/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/creeper_apocalypse/main.py b/src/datapack/modes/creeper_apocalypse/main.py
index 0475d9a79..381d17d66 100644
--- a/src/datapack/modes/creeper_apocalypse/main.py
+++ b/src/datapack/modes/creeper_apocalypse/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/creeper_apocalypse/translations.py b/src/datapack/modes/creeper_apocalypse/translations.py
index 08f03e935..da25f1a1e 100644
--- a/src/datapack/modes/creeper_apocalypse/translations.py
+++ b/src/datapack/modes/creeper_apocalypse/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/de_a_coudre/main.py b/src/datapack/modes/de_a_coudre/main.py
index c8d93d8b8..201808098 100644
--- a/src/datapack/modes/de_a_coudre/main.py
+++ b/src/datapack/modes/de_a_coudre/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/definitions.py b/src/datapack/modes/definitions.py
index 8180f058d..3d3b6d9d7 100644
--- a/src/datapack/modes/definitions.py
+++ b/src/datapack/modes/definitions.py
@@ -1,517 +1,201 @@
+""" Storage entries built from the minigame catalogue.
+Turns the declarative MODES and GROUPS tables into the "minigames" and "groups" lists that the
+vote, the lore and the starting pop-ups read at runtime.
"""
-# French
-# data modify storage switch:main minigames append value {min_players:2 ,max_players:-1 ,id:"replicate_the_build" ,name_fr:"Replicate The Build" ,lore_fr:["",{"text":"[Replicate The Build]\n","color":"yellow"}, {"text":"Refaites le build donné le plus rapidement possible, et sans vous tromper !\n"}, {"text":"\n[Estimation : 1-6 mins]","color":"gold"}, {"text":"\n[Inspiration : Mineplex]","color":"green"}, {"text":"\n[Proposé/Développé par AirDox]","color":"aqua"}]}
-
-# data modify storage switch:main minigames[{id:"replicate_the_build"}].lore_en set value ["", {"text":"[Replicate The Build]\n","color":"yellow"}, {"text":"Repeat the given build as quickly as possible, without making any mistakes!\n"}, {"text":"\n[Estimated: 1-6 mins]","color":"gold"}, {"text":"\n[Inspiration: Mineplex]","color":"green"}, {"text":"\n[Suggested/Developed by AirDox]","color":"aqua"}]}
-""" # noqa: E501
-
# Imports
import json
from stewbeet import Mem, TextComponent, write_function
from stouputils.typing import JsonDict
-# Modes list
-MODES: list[JsonDict] = [
- {
- "min_players":1, "max_players":-1, "id":"pitch_creep", "group":"creepers", "name_fr":"Pitch Creep",
- "estimated_time": "1m30s", "inspiration": "Pitchout/Épicube", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Survivez en repoussant les creepers\n"},{"text":"dans une arène fermée.\n"}],
- "en": [{"text":"Survive by pushing away creepers\n"},{"text":"in a closed arena.\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"laser_game", "name_fr":"Laser Game",
- "estimated_time": "2m30s", "inspiration": "The physical world", "suggested_by": "Vigo", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Affrontez l'équipe adverse grâce à vos fusils-laser et faites\n"},{"text":"le plus d'élimination possible pendant un temps imparti !\n"}],
- "en": [{"text":"Battle the enemy team with your laser rifles and get\n"},{"text":"as many takedowns as possible within the allotted time!\n"}]
- },
- },
- {
- "min_players":1, "max_players":-1, "id":"warden_escape", "name_fr":"Warden Escape",
- "estimated_time": "1m30s", "inspiration": "Minecraft 1.19", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Survivez aux wardens dans\n"},{"text":"une arène fermée.\n"}],
- "en": [{"text":"Survive the wardens in\n"},{"text":"a closed arena.\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"pitchout", "name_fr":"Pitchout",
- "estimated_time": "1-5 mins", "inspiration": "Épicube", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Poussez vos adversaires dans l'eau\n"},{"text":"et soyez le dernier survivant !\n"}],
- "en": [{"text":"Push your opponents into the water\n"},{"text":"and be the last survivor!\n"}]
- },
- },
- {
- "min_players":1, "max_players":-1, "id":"creeper_apocalypse", "group":"creepers", "name_fr":"Creeper Apocalypse",
- "estimated_time": "1m30s", "inspiration": "Expiration", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Survivez aux creepers qui se\n"},{"text":"multiplient et explosent.\n"}],
- "en": [{"text":"Survive the creepers that\n"},{"text":"duplicate and explode.\n"}]
- },
- },
- {
- "min_players":4, "max_players":-1, "id":"traitors_game", "name_fr":"Traitors Game",
- "estimated_time": "4-10 mins", "inspiration": "Taupe Gun UHC", "suggested_by": "Luxio", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Des traitres se cachent parmi les joueurs.\n"},{"text":"Innocents contre Traitres, quel camp va-t-il gagner ?\n"}],
- "en": [{"text":"Traitors are hinding among players.\n"},{"text":"Innocents versus Traitors, which side will win?\n"}]
- },
- },
- # {
- # "min_players":1, "max_players":-1, "id":"boat_race", "name_fr":"Boat Race",
- # "estimated_time": "3-8 mins", "inspiration": "Expiration", "suggested_by": "Stoupy", "developed_by": "LTHCTheMaster",
- # "description": {
- # "fr": [{"text":"Battez-vous pour atteindre la ligne d'arrivée\n"},{"text":"en premier pour remporter la partie !\n"}],
- # "en": [{"text":"Race for reaching the finish line\n"},{"text":"in first to claim the win!\n"}]
- # },
- # },
- {
- "min_players":2, "max_players":-1, "id":"spectres_game", "name_fr":"Spectres Game",
- "estimated_time": "4-10 mins", "inspiration": "Expiration", "suggested_by": "Luxio", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Deux camps : un avec que des joueurs visibles,\n"},{"text":"l'autre avec des spectres, quel camp va-t-il gagner ?\n"}],
- "en": [{"text":"Two sides: one with visible players,\n"},{"text":"the other with spectres, which side will win?\n"}]
- },
- },
- {
- "min_players":1, "max_players":-1, "id":"race", "name_fr":"Race",
- "estimated_time": "3-6 mins", "inspiration": "Racing games", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Battez-vous pour atteindre la ligne d'arrivée\n"},{"text":"en premier pour remporter la partie !\n"}],
- "en": [{"text":"Race for reaching the finish line\n"},{"text":"in first to claim the win!\n"}]
- },
- },
- {
- "min_players":8, "max_players":-1, "id":"rush_the_point", "group":"ctpf", "name_fr":"Rush The Point",
- "estimated_time": "7-10 mins", "inspiration": "PluginLess", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Capturez les points centraux dans le vide\n"},{"text":"avec vos classes spéciales.\n"}],
- "en": [{"text":"Capture the central points in the void\n"},{"text":"with your special classes.\n"}]
- },
- },
- {
- "min_players":8, "max_players":-1, "id":"rush_the_flag", "group":"ctpf", "name_fr":"Rush The Flag",
- "estimated_time": "5-10 mins", "inspiration": "PluginLess", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Volez le drapeau ennemi\n"},{"text":"avec vos classes spéciales.\n"}],
- "en": [{"text":"Steal the enemy flag\n"},{"text":"with your special classes.\n"}]
- },
- },
- {
- "min_players":1, "max_players":-1, "id":"spleef", "name_fr":"Spleef",
- "estimated_time": "1-4 mins", "inspiration": "everywhere", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Creusez sous vos adversaires\n"},{"text":"pour les faire tomber !\n"}],
- "en": [{"text":"Dig below your opponents\n"},{"text":"to make them fall!\n"}]
- },
- },
- {
- "min_players":1, "max_players":-1, "id":"de_a_coudre", "group":"a_coudre", "name_fr":"Dé à Coudre",
- "estimated_time": "3-6 mins", "inspiration": "OneCube point system", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Affrontez les autres joueurs dans un saut,\n"},{"text":"dans une piscine, iconique de Minecraft !\n"}],
- "en": [{"text":"Take on the other players in\n"},{"text":"Minecraft's iconic pool jump!\n"}]
- },
- },
- {
- "min_players":1, "max_players":-1, "id":"feed_fast", "name_fr":"Feed Fast",
- "estimated_time": "60s", "inspiration": "Le diabète", "suggested_by": "Arobaze", "developed_by": "LTHCTheMaster",
- "description": {
- "fr": [{"text":"Mangez le plus de nourriture\n"},{"text":"possible pour gagner !\n"}],
- "en": [{"text":"Eat the most food\n"},{"text":"possible to win!\n"}]
- },
- },
- {
- "min_players":1, "max_players":8, "id":"mlg_a_coudre", "group":"a_coudre", "name_fr":"MLG à Coudre",
- "estimated_time": "1-6 mins", "inspiration": "Dé à Coudre", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Sautez et utilisez votre seau d'eau\n"},{"text":"pour survivre à la chute !\n"}],
- "en": [{"text":"Jump and use your water bucket\n"},{"text":"to survive the fall!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"protect_the_king", "name_fr":"Protect The King",
- "estimated_time": "1-4 mins", "inspiration": "Charlemagne", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Tuez le roi ennemi ou\n"},{"text":"protégez le vôtre !\n"}],
- "en": [{"text":"Kill the enemy king or\n"},{"text":"protect your own!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"castagne", "name_fr":"Castagne RP",
- "estimated_time": "1-5 mins", "inspiration": "Domestic Violence", "suggested_by": "JustNoob", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Battez-vous au corps à corps\n"},{"text":"et soyez le dernier survivant !\n"}],
- "en": [{"text":"Fight hand-to-hand combat\n"},{"text":"and be the last survivor!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"layers_2_teams", "group":"layers", "name_fr":"Layers 2 Teams",
- "estimated_time": "4-10 mins", "inspiration": "PenguinGames", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Équipez-vous rapidement et détruisez\n"},{"text":"l'équipe adverse située de l'autre côté du mur !\n"}],
- "en": [{"text":"Gear up quickly and destroy the enemy team\n"},{"text":"on the other side of the wall!\n"}]
- },
- },
- {
- "min_players":4, "max_players":-1, "id":"layers_4_teams", "group":"layers", "name_fr":"Layers 4 Teams",
- "estimated_time": "4-10 mins", "inspiration": "PenguinGames", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Course d'équipement entre\n"},{"text":"4 équipes, éliminez vos ennemis !\n"}],
- "en": [{"text":"Equipment race between\n"},{"text":"4 teams, eliminate your enemies!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"thunder_spear", "name_fr":"Thunder Spear",
- "estimated_time": "2m30s", "inspiration": "SNK/Attack On Titan", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Utilisez votre équipement tridimensionnel\n"},{"text":"et vos lances explosives !\n"}],
- "en": [{"text":"Use your three-dimensional maneuver gear\n"},{"text":"and explosive spears!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"tnt_run", "name_fr":"TNT Run",
- "estimated_time": "1-4 mins", "inspiration": "Grian (2013)", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Restez en mouvement, les blocs\n"},{"text":"disparaissent sous vos pieds !\n"}],
- "en": [{"text":"Keep moving, blocks disappear\n"},{"text":"under your feet!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"sheepwars", "name_fr":"SheepWars",
- "estimated_time": "2-6 mins", "inspiration": "Épicube", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Lancez des moutons explosifs\n"},{"text":"sur l'équipe ennemie !\n"}],
- "en": [{"text":"Launch explosive sheep\n"},{"text":"at the enemy team!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"block_party", "name_fr":"Block Party",
- "estimated_time": "1-5 mins", "inspiration": "HiveMC", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Quand la musique s'arrête,\n"},{"text":"trouvez la bonne couleur !\n"}],
- "en": [{"text":"When the music stops,\n"},{"text":"find the right color!\n"}]
- },
- },
- {
- "min_players":2, "max_players":16, "id":"moutron", "name_fr":"Moutron",
- "estimated_time": "1-3 mins", "inspiration": "Épicube", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Montez un mouton qui laisse\n"},{"text":"une traînée mortelle !\n"}],
- "en": [{"text":"Ride a sheep that leaves\n"},{"text":"a deadly trail!\n"}]
- },
- },
- {
- "min_players":2, "max_players":45, "id":"snowball_painter", "name_fr":"Snowball Painter",
- "estimated_time": "45s", "inspiration": "Party Games/Hypixel", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Peignez le plus de blocs possible\n"},{"text":"avec vos boules de neige !\n"}],
- "en": [{"text":"Paint the most blocks possible\n"},{"text":"with your snowballs!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"gay_shooter", "name_fr":"Guy Shooter",
- "estimated_time": "1-5 mins", "inspiration": "a Survisland Event", "suggested_by": "GoldVision & scartvol", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Archers contre grimpeurs :\n"},{"text":"empêchez-les d'atteindre le sommet !\n"}],
- "en": [{"text":"Archers versus climbers:\n"},{"text":"prevent them from reaching the top!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"build_battle", "name_fr":"Build Battle",
- "estimated_time": "5-7 mins", "inspiration": "Hypixel", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Construisez sur un thème\n"},{"text":"et votez pour le meilleur build !\n"}],
- "en": [{"text":"Build on a theme and\n"},{"text":"vote for the best build!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"beat_the_kings", "name_fr":"Beat The Kings",
- "estimated_time": "3-5 mins", "inspiration": "Henry IV/Louis XVI", "suggested_by": "Luxio", "developed_by": "Luxio",
- "description": {
- "fr": [{"text":"Révolutionnaires contre rois :\n"},{"text":"qui survivra à la bataille ?\n"}],
- "en": [{"text":"Revolutionaries versus kings:\n"},{"text":"who will survive the battle?\n"}]
- },
- },
- {
- "min_players":1, "max_players":-1, "id":"coin_chaser", "name_fr":"Coin Chaser",
- "estimated_time": "1m30s", "inspiration": "Expiration", "suggested_by": "Ikinox", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Collectez le plus d'or possible\n"},{"text":"avec vos élytras !\n"}],
- "en": [{"text":"Collect the most gold possible\n"},{"text":"with your elytra!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"murder_mystery", "name_fr":"Murder Mystery",
- "estimated_time": "1-4 mins", "inspiration": "Hypixel", "suggested_by": "GoldVision", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Un meurtrier, un détective,\n"},{"text":"et des innocents dans une map pleine d'or !\n"}],
- "en": [{"text":"A murderer, a detective,\n"},{"text":"and innocents in a map full of gold!\n"}]
- },
- },
- {
- "min_players":4, "max_players":-1, "id":"panic_chase", "name_fr":"Panic Chase",
- "estimated_time": "1-2 mins", "inspiration": "Le chat et la souris", "suggested_by": "Arti", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Survivez aux chasseurs dans\n"},{"text":"une petite arène fermée !\n"}],
- "en": [{"text":"Survive the hunters in\n"},{"text":"a small closed arena!\n"}]
- },
- },
- {
- "min_players":1, "max_players":-1, "id":"simultaneous_jump", "group":"a_coudre", "name_fr":"Simultaneous Jump",
- "estimated_time": "60s", "inspiration": "Dé à Coudre/MLG à Coudre", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Dé à coudre ou MLG à coudre,\n"},{"text":"mais tout le monde saute en même temps !\n"}],
- "en": [{"text":"Pool jump or MLG water bucket,\n"},{"text":"but everyone jumps at the same time!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"bombardement", "name_fr":"Bombardment",
- "estimated_time": "1-2 mins", "inspiration": "Hypixel", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Esquivez les tirs de ce bâteau volant\n"},{"text":"afin d'être le dernier survivant !\n"}],
- "en": [{"text":"Dodge the shots from this flying boat\n"},{"text":"to be the last survivor!\n"}]
- },
- },
- {
- "min_players":1, "max_players":-1, "id":"shoot_da_sheep", "name_fr":"Shoot Da'Sheep",
- "estimated_time": "60s", "inspiration": "BrawlMC", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Des moutons tombent du ciel, certains donnent plus de points\n"},{"text":"que d'autres, ayez le plus de points à la fin !\n"}],
- "en": [{"text":"Sheeps are falling from the sky, some give more points\n"},{"text":"than others, get the most points at the end!\n"}]
- },
- },
- {
- "min_players":1, "max_players":-1, "id":"minigolf", "name_fr":"MiniGolf",
- "estimated_time": "1-4 mins", "inspiration": "The physical world", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Soyez celui qui réussi à faire tomber sa balle dans\n"},{"text":"le trou avec le moins de coup possible !\n"}],
- "en": [{"text":"Be the one to drop your golf ball in\n"},{"text":"the hole with the fewest strokes possible!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"cigogne", "name_fr":"Cigogne",
- "estimated_time": "1-4 mins", "inspiration": "ShoupZ/JustNoob", "suggested_by": "GoldVision", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Combat de spectres puissants\n"},{"text":"dans une salle fermée !\n"}],
- "en": [{"text":"Powerful spectres battle\n"},{"text":"in a closed room!\n"}]
- },
- },
- {
- "min_players":8, "max_players":-1, "id":"infected", "name_fr":"Infected",
- "estimated_time": "5-10 mins", "inspiration": "ImagineYourCraft (2013)", "suggested_by": "the whole Earth", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Un infecté est choisi en début de partie et doit infecter\n"},{"text":"tous les humains qui doivent attendre les renforts !\n"}],
- "en": [{"text":"An infected person is chosen at the beginning of the game and\n"},{"text":"must infect all humans who must wait for rescue!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"glassrunner", "group":"ctpf", "name_fr":"GlassRunner",
- "estimated_time": "8-10 mins", "inspiration": "Épicube", "suggested_by": "AirDox", "developed_by": "AirDox",
- "description": {
- "fr": [{"text":"Affrontez l'équipe adverse dans le ciel dans un\n"},{"text":"CaptureThePoint assaisonné de multiples particularités\n"}],
- "en": [{"text":"Take on the opposing team in the sky in a\n"},{"text":"CaptureThePoint seasoned with multiple special features\n"}]
- },
- },
- {
- "min_players":10, "max_players":-1, "id":"capture_the_flag", "group":"ctpf", "name_fr":"Capture The Flag",
- "estimated_time": "10-15 mins", "inspiration": "Aspiration", "suggested_by": "Oraclette", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Deux équipes doivent s'affronter pour capturer\n"},{"text":"le drapeau ennemi grâce à des\n"},{"text":"classes spécifiques différentes\n"}],
- "en": [{"text":"Two teams must compete to capture the enemy flag\n"},{"text":"with the help of different specific classes\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"memory_mine", "name_fr":"Memory Mine",
- "estimated_time": "1-2 mins", "inspiration": "Pico Party", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Mémorisez la position des mines\n"},{"text":"qui clignotent et disparaissent !\n"}],
- "en": [{"text":"Memorize the position of mines\n"},{"text":"that blink and disappear!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"fish_fight", "name_fr":"Fish Fight",
- "estimated_time": "2-4 mins", "inspiration": "Findus", "suggested_by": "Luxio", "developed_by": "Luxio",
- "description": {
- "fr": [{"text":"Éjectez vos adversaires\n"},{"text":"avec votre poisson !\n"}],
- "en": [{"text":"Eject your opponents\n"},{"text":"with your fish!\n"}]
- },
- },
- {
- "min_players":2, "max_players":10, "id":"one_shot", "name_fr":"One Shot",
- "estimated_time": "2-4 mins", "inspiration": "OneShot", "suggested_by": "Luxio", "developed_by": "Luxio",
- "description": {
- "fr": [{"text":"Éliminez vos adversaires grâce à votre arc\n"},{"text":"surpuissant et votre épée pour remporter la victoire !\n"}],
- "en": [{"text":"Eliminate your opponents with your\n"},{"text":"powerful bow and sword to claim victory!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"pillars_of_fortune", "name_fr":"Pillars of Fortune",
- "estimated_time": "1-6 mins", "inspiration": "Cubecraft", "suggested_by": "TreekoZ", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Recevez des objets aléatoires\n"},{"text":"sur de grands piliers !\n"}],
- "en": [{"text":"Receive random items\n"},{"text":"on large pillars!\n"}]
- },
- },
- {
- "min_players":2, "max_players":-1, "id":"fireblast", "name_fr":"Fireblast",
- "estimated_time": "1-2 mins", "inspiration": "LifeCraft", "suggested_by": "Stoupy", "developed_by": "Stoupy",
- "description": {
- "fr": [{"text":"Éjectez tous les joueurs de la plateforme\n"},{"text":"en utilisant des boules de feu explosives !\n"}],
- "en": [{"text":"Knock out every players from the platform\n"},{"text":"by using explosive fireballs!\n"}]
- },
- },
- # {
- # "min_players":2, "max_players":32, "id":"pvpswap", "name_fr":"PvPSwap",
- # "estimated_time": "1-2 mins", "inspiration": "Épicube", "suggested_by": "Arobaze", "developed_by": "Arobaze",
- # "description": {
- # "fr": [{"text":"Éliminez les autres joueurs au travers\n"},{"text":"des changements inopinés de positions !\n"}],
- # "en": [{"text":"Eliminate other players through\n"},{"text":"sudden changes in positions!\n"}]
- # },
- # }
-]
-
-# Voting groups: games sharing the same "group" key show up as a single vote entry
-# (a game without a "group" key gets its own group, defaulting to its id).
-# When a group wins the vote, a second vote is launched between its games (or the game is launched directly if only one fits the player count).
-GROUP_NAME_COLOR: str = "#FFCC00" # Between yellow and gold, distinguishes multi-game groups in the vote message
-GROUPS_INFO: dict[str, JsonDict] = {
- "a_coudre": {
- "name_fr": "Dé À Coudre & Co",
- "estimated_time": "1-6 mins",
- "description": {
- "fr": [{"text":"Sautez dans la piscine, avec ou sans seau d'eau,\n"},{"text":"tous en même temps ou chacun son tour !\n"}],
- "en": [{"text":"Jump into the pool, with or without a water bucket,\n"},{"text":"all at once or one at a time!\n"}]
- },
- },
- "creepers": {
- "name_fr": "Creepers",
- "estimated_time": "1m30s",
- "description": {
- "fr": [{"text":"Survivez aux creepers, en les repoussant\n"},{"text":"ou en fuyant leurs explosions !\n"}],
- "en": [{"text":"Survive the creepers, by pushing them away\n"},{"text":"or fleeing their explosions!\n"}]
- },
- },
- "layers": {
- "name_fr": "Layers",
- "estimated_time": "4-10 mins",
- "description": {
- "fr": [{"text":"Équipez-vous rapidement et détruisez\n"},{"text":"les équipes adverses !\n"}],
- "en": [{"text":"Gear up quickly and destroy\n"},{"text":"the enemy teams!\n"}]
- },
- },
- "ctpf": {
- "name_fr": "Capture the Point/Flag",
- "estimated_time": "5-15 mins",
- "description": {
- "fr": [{"text":"Affrontez l'équipe adverse : capturez des points,\n"},{"text":"volez des drapeaux ou courez sur le verre !\n"}],
- "en": [{"text":"Face off against the enemy team: capture points,\n"},{"text":"steal flags or run across the glass!\n"}]
- },
- },
-}
+from .catalogue import GROUPS_BY_ID, MODES
+from .spec import GroupSpec, ModeSpec
+
+# Constants
+GROUP_NAME_COLOR: str = "#FFCC00"
+""" Between yellow and gold, distinguishes multi-game groups in the vote message. """
-# Language pretext constant
LANG_PRETEXT: dict[str, dict[str, str]] = {
"fr": {"estimated_time": "Estimation :", "inspiration": "Inspiration :", "suggested": "Proposé par", "developed": "Développé par"},
"en": {"estimated_time": "Estimated:", "inspiration": "Inspiration:", "suggested": "Suggested by", "developed": "Developed by"},
}
+""" Lore labels for a single mode, per language. """
-# Language pretext constant for group lores
LANG_GROUP_PRETEXT: dict[str, dict[str, str]] = {
"fr": {"includes": "Contient :", "second_vote": "Un second vote départagera la variante"},
"en": {"includes": "Includes:", "second_vote": "A second vote will decide the variant"},
}
-
-# Add the lores to the modes
-def add_lore():
- for i, mode in enumerate(MODES):
- description: dict[str, list[TextComponent]] = mode["description"]
-
- # Add the missing lore for each language
- for lang, lore in description.items():
- pretext: dict[str, str] = LANG_PRETEXT[lang]
- lore.insert(0, "")
- lore.insert(1, {"text":f"[{mode['name_fr']}]\n","color":"yellow"})
- lore.append({"text":f"\n[{pretext['estimated_time']} {mode['estimated_time']}]","color":"gold"})
- lore.append({"text":f"\n[{pretext['inspiration']} {mode['inspiration']}]","color":"green"})
- lore.append({"text":f"\n[{pretext['suggested']} {mode['suggested_by']}]","color":"aqua"})
- lore.append({"text":f"\n[{pretext['developed']} {mode['developed_by']}]","color":"aqua"})
- lore_key: str = f"lore_{lang}"
- mode[lore_key] = lore
-
- # Add keys for index and index*100 (utility for things such as /rating, /coupdetat, etc.)
- mode["index"] = i + 1
- mode["index_hundred"] = (i + 1) * 100
-
- # Colored name shown in the vote message: the color must be baked in the stored component because
- # the outer style of an interpreted nbt component overrides the parsed one (ComponentUtils.resolve)
- mode["display_name_fr"] = {"text": mode["name_fr"], "color": "yellow"}
- mode["display_name_en"] = {"text": mode.get("name_en", mode["name_fr"]), "color": "yellow"}
-
- # Default the voting group to the game id (games sharing a group are voted as one entry)
- mode.setdefault("group", mode["id"])
-
- del mode["description"]
-
-def get_groups() -> list[JsonDict]:
- """ Build the voting groups list from MODES (must be called after add_lore()). """
- members: dict[str, list[JsonDict]] = {}
+""" Extra lore labels for a multi-mode group, per language. """
+
+
+
+# Functions
+def mode_lore(mode: ModeSpec, lang: str) -> list[TextComponent]:
+ """ Build the vote lore of one mode: title, description lines, then the four credit lines.
+
+ Args:
+ mode (ModeSpec): The mode to describe
+ lang (str): Language key, ex: "fr" or "en"
+ Returns:
+ list[TextComponent]: The lore, ready to be stored
+ """
+ pretext: dict[str, str] = LANG_PRETEXT[lang]
+ description: tuple[str, ...] = mode.description.fr if lang == "fr" else mode.description.en
+ return [
+ "",
+ {"text": f"[{mode.name_fr}]\n", "color": "yellow"},
+ *({"text": f"{line}\n"} for line in description),
+ {"text": f"\n[{pretext['estimated_time']} {mode.estimated_time}]", "color": "gold"},
+ {"text": f"\n[{pretext['inspiration']} {mode.inspiration}]", "color": "green"},
+ {"text": f"\n[{pretext['suggested']} {mode.suggested_by}]", "color": "aqua"},
+ {"text": f"\n[{pretext['developed']} {mode.developed_by}]", "color": "aqua"},
+ ]
+
+
+def group_lore(group: GroupSpec, modes: tuple[ModeSpec, ...], lang: str) -> list[TextComponent]:
+ """ Build the vote lore of a multi-mode group: title, description, duration, members, hint.
+
+ Args:
+ group (GroupSpec): The group to describe
+ modes (tuple[ModeSpec...]): Its members, in vote order
+ lang (str): Language key, ex: "fr" or "en"
+ Returns:
+ list[TextComponent]: The lore, ready to be stored
+ """
+ pretext: dict[str, str] = LANG_PRETEXT[lang]
+ group_pretext: dict[str, str] = LANG_GROUP_PRETEXT[lang]
+ description: tuple[str, ...] = group.description.fr if lang == "fr" else group.description.en
+ included: str = ", ".join(mode.name_fr for mode in modes)
+ return [
+ "",
+ {"text": f"[{group.name_fr}]\n", "color": GROUP_NAME_COLOR},
+ *({"text": f"{line}\n"} for line in description),
+ {"text": f"\n[{pretext['estimated_time']} {group.estimated_time}]", "color": "gold"},
+ {"text": f"\n[{group_pretext['includes']} {included}]", "color": "green"},
+ {"text": f"\n[{group_pretext['second_vote']}]", "color": "aqua"},
+ ]
+
+
+def storage_max_players(max_players: int | None) -> int:
+ """ Storage form of a player cap, where unlimited is -1.
+
+ Args:
+ max_players (int | None): The cap, or UNLIMITED
+ Returns:
+ int: The stored value
+
+ Examples:
+ >>> storage_max_players(UNLIMITED)
+ -1
+ >>> storage_max_players(8)
+ 8
+ """
+ return -1 if max_players is None else max_players
+
+
+def mode_entry(mode: ModeSpec, index: int) -> JsonDict:
+ """ Storage entry of one mode, appended to the "minigames" list.
+
+ Key insertion order is part of the generated output: an explicit group sits right after the
+ id, a defaulted one is added last. Reordering rewrites every function file for nothing.
+
+ Args:
+ mode (ModeSpec): The mode to store
+ index (int): Its 1-based rank, used by /rating and /coupdetat
+ Returns:
+ JsonDict: The entry
+ """
+ entry: JsonDict = {
+ "min_players": mode.min_players,
+ "max_players": storage_max_players(mode.max_players),
+ "id": mode.id,
+ }
+ if mode.group is not None:
+ entry["group"] = mode.group
+ entry["name_fr"] = mode.name_fr
+ entry["estimated_time"] = mode.estimated_time
+ entry["inspiration"] = mode.inspiration
+ entry["suggested_by"] = mode.suggested_by
+ entry["developed_by"] = mode.developed_by
+ entry["lore_fr"] = mode_lore(mode, "fr")
+ entry["lore_en"] = mode_lore(mode, "en")
+ entry["index"] = index
+ entry["index_hundred"] = index * 100
+ entry["display_name_fr"] = {"text": mode.display_name("fr"), "color": "yellow"}
+ entry["display_name_en"] = {"text": mode.display_name("en"), "color": "yellow"}
+ if mode.group is None:
+ entry["group"] = mode.id
+ return entry
+
+
+def group_entry(group_id: str, modes: tuple[ModeSpec, ...], index: int) -> JsonDict:
+ """ Storage entry of one vote group, appended to the "groups" list.
+
+ A group holding a single mode is the mode itself, so the first vote shows it directly.
+
+ Args:
+ group_id (str): The group id, ex: "layers"
+ modes (tuple[ModeSpec...]): Its members, in vote order
+ index (int): Its 1-based rank
+ Returns:
+ JsonDict: The entry
+ """
+ if len(modes) == 1:
+ mode: ModeSpec = modes[0]
+ return {
+ "min_players": mode.min_players,
+ "max_players": storage_max_players(mode.max_players),
+ "id": group_id,
+ "name_fr": mode.name_fr,
+ "name_en": mode.display_name("en"),
+ "display_name_fr": {"text": mode.display_name("fr"), "color": "yellow"},
+ "display_name_en": {"text": mode.display_name("en"), "color": "yellow"},
+ "lore_fr": mode_lore(mode, "fr"),
+ "lore_en": mode_lore(mode, "en"),
+ "index": index,
+ }
+
+ group: GroupSpec = GROUPS_BY_ID[group_id]
+ bounded: list[int] = [mode.max_players for mode in modes if mode.max_players is not None]
+ return {
+ "min_players": min(mode.min_players for mode in modes),
+ "max_players": max(bounded) if len(bounded) == len(modes) else -1,
+ "id": group_id,
+ "name_fr": group.name_fr,
+ "name_en": group.display_name("en"),
+ "display_name_fr": {"text": group.name_fr, "color": GROUP_NAME_COLOR},
+ "display_name_en": {"text": group.display_name("en"), "color": GROUP_NAME_COLOR},
+ "lore_fr": group_lore(group, modes, "fr"),
+ "lore_en": group_lore(group, modes, "en"),
+ "index": index,
+ }
+
+
+def grouped_modes() -> dict[str, tuple[ModeSpec, ...]]:
+ """ Modes bucketed by vote group, keeping the declaration order of MODES.
+
+ Returns:
+ dict[str, tuple[ModeSpec, ...]]: Group id -> its members
+ """
+ members: dict[str, list[ModeSpec]] = {}
for mode in MODES:
- members.setdefault(mode["group"], []).append(mode)
-
- groups: list[JsonDict] = []
- for i, (group_id, modes) in enumerate(members.items()):
- if len(modes) == 1:
- # Single game: the group entry is the game itself
- mode = modes[0]
- entry: JsonDict = {
- "min_players": mode["min_players"], "max_players": mode["max_players"], "id": group_id,
- "name_fr": mode["name_fr"], "name_en": mode.get("name_en", mode["name_fr"]),
- "display_name_fr": mode["display_name_fr"], "display_name_en": mode["display_name_en"],
- "lore_fr": mode["lore_fr"], "lore_en": mode["lore_en"],
- }
- else:
- # Multiple games: aggregate player bounds and build a dedicated lore from GROUPS_INFO
- # (the display_name carries GROUP_NAME_COLOR so the vote message distinguishes multi-game groups)
- info: JsonDict = GROUPS_INFO[group_id]
- entry = {
- "min_players": min(mode["min_players"] for mode in modes),
- "max_players": -1 if any(mode["max_players"] == -1 for mode in modes) else max(mode["max_players"] for mode in modes),
- "id": group_id,
- "name_fr": info["name_fr"], "name_en": info.get("name_en", info["name_fr"]),
- "display_name_fr": {"text": info["name_fr"], "color": GROUP_NAME_COLOR},
- "display_name_en": {"text": info.get("name_en", info["name_fr"]), "color": GROUP_NAME_COLOR},
- }
- included: str = ", ".join(mode["name_fr"] for mode in modes)
- for lang in ("fr", "en"):
- pretext: dict[str, str] = LANG_PRETEXT[lang]
- group_pretext: dict[str, str] = LANG_GROUP_PRETEXT[lang]
- entry[f"lore_{lang}"] = [
- "",
- {"text":f"[{info['name_fr']}]\n","color":GROUP_NAME_COLOR},
- *info["description"][lang],
- {"text":f"\n[{pretext['estimated_time']} {info['estimated_time']}]","color":"gold"},
- {"text":f"\n[{group_pretext['includes']} {included}]","color":"green"},
- {"text":f"\n[{group_pretext['second_vote']}]","color":"aqua"},
- ]
- entry["index"] = i + 1
- groups.append(entry)
- return groups
-
-def write_modes_load_file():
- ns: str = Mem.ctx.project_id
- add_lore()
- append_lines: list[str] = [f"data modify storage {ns}:main minigames append value " + json.dumps(mode, ensure_ascii=False) for mode in MODES]
+ members.setdefault(mode.vote_group, []).append(mode)
+ return {group_id: tuple(modes) for group_id, modes in members.items()}
- # Voting groups list (what the first vote is made of)
- append_lines.append("\n# Voting groups (the first vote shows groups, a second vote decides between the games of the winning group)")
- append_lines.append(f"data modify storage {ns}:main groups set value []")
- append_lines += [f"data modify storage {ns}:main groups append value " + json.dumps(group, ensure_ascii=False) for group in get_groups()]
- write_function(f"{ns}:modes/load", "\n".join(append_lines))
+def write_modes_load_file() -> None:
+ """ Write switch:modes/load, which fills the minigames and groups storage lists. """
+ ns: str = Mem.ctx.project_id
+ lines: list[str] = [
+ f"data modify storage {ns}:main minigames append value " + json.dumps(mode_entry(mode, index), ensure_ascii=False)
+ for index, mode in enumerate(MODES, start=1)
+ ]
+ lines.append("\n# Voting groups (the first vote shows groups, a second vote decides between the games of the winning group)")
+ lines.append(f"data modify storage {ns}:main groups set value []")
+ lines += [
+ f"data modify storage {ns}:main groups append value " + json.dumps(group_entry(group_id, modes, index), ensure_ascii=False)
+ for index, (group_id, modes) in enumerate(grouped_modes().items(), start=1)
+ ]
+ write_function(f"{ns}:modes/load", "\n".join(lines))
diff --git a/src/datapack/modes/common.py b/src/datapack/modes/emit.py
similarity index 100%
rename from src/datapack/modes/common.py
rename to src/datapack/modes/emit.py
diff --git a/src/datapack/modes/feed_fast/main.py b/src/datapack/modes/feed_fast/main.py
index c135a2413..85d7d01bc 100644
--- a/src/datapack/modes/feed_fast/main.py
+++ b/src/datapack/modes/feed_fast/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/feed_fast/translations.py b/src/datapack/modes/feed_fast/translations.py
index e3fac2a4c..d004725eb 100644
--- a/src/datapack/modes/feed_fast/translations.py
+++ b/src/datapack/modes/feed_fast/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/fireblast/main.py b/src/datapack/modes/fireblast/main.py
index 2468d58ed..de929df69 100644
--- a/src/datapack/modes/fireblast/main.py
+++ b/src/datapack/modes/fireblast/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls
+from ..emit import write_modes_calls
from .translations import write_translations
diff --git a/src/datapack/modes/fireblast/translations.py b/src/datapack/modes/fireblast/translations.py
index b88b012c6..265d4f8d9 100644
--- a/src/datapack/modes/fireblast/translations.py
+++ b/src/datapack/modes/fireblast/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/fish_fight/main.py b/src/datapack/modes/fish_fight/main.py
index b03b9fa01..1a1420c19 100644
--- a/src/datapack/modes/fish_fight/main.py
+++ b/src/datapack/modes/fish_fight/main.py
@@ -4,7 +4,7 @@
from stewbeet import Mem, write_function
from ...kits import Kit, KitItem
-from ..common import write_modes_calls, write_no_drop
+from ..emit import write_modes_calls, write_no_drop
from .translations import write_translations
diff --git a/src/datapack/modes/gay_shooter/main.py b/src/datapack/modes/gay_shooter/main.py
index ce7cd5682..06cf5ff3b 100644
--- a/src/datapack/modes/gay_shooter/main.py
+++ b/src/datapack/modes/gay_shooter/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls
+from ..emit import write_modes_calls
from .translations import write_translations
diff --git a/src/datapack/modes/gay_shooter/translations.py b/src/datapack/modes/gay_shooter/translations.py
index cebe87faa..393cecaa7 100644
--- a/src/datapack/modes/gay_shooter/translations.py
+++ b/src/datapack/modes/gay_shooter/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/glassrunner/main.py b/src/datapack/modes/glassrunner/main.py
index 699aa53b3..01a7e6736 100644
--- a/src/datapack/modes/glassrunner/main.py
+++ b/src/datapack/modes/glassrunner/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls
+from ..emit import write_modes_calls
from .translations import write_translations
diff --git a/src/datapack/modes/glassrunner/resources.py b/src/datapack/modes/glassrunner/resources.py
index 050db92d4..3a373b169 100644
--- a/src/datapack/modes/glassrunner/resources.py
+++ b/src/datapack/modes/glassrunner/resources.py
@@ -5,7 +5,7 @@
from beet import BlockTag, ItemModifier, LootTable, Predicate
from stewbeet.core import Mem, set_json_encoder
-from ..common import register_structures
+from ..emit import register_structures
def holding_predicate(flag: str) -> dict[str, Any]:
diff --git a/src/datapack/modes/glassrunner/translations.py b/src/datapack/modes/glassrunner/translations.py
index 80978104a..881660e59 100644
--- a/src/datapack/modes/glassrunner/translations.py
+++ b/src/datapack/modes/glassrunner/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/infected/main.py b/src/datapack/modes/infected/main.py
index ebb16400a..4084f0cbb 100644
--- a/src/datapack/modes/infected/main.py
+++ b/src/datapack/modes/infected/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/infected/shop.py b/src/datapack/modes/infected/shop.py
index 29f0d1be8..476942598 100644
--- a/src/datapack/modes/infected/shop.py
+++ b/src/datapack/modes/infected/shop.py
@@ -2,7 +2,7 @@
# Infected shop
from stouputils import JsonDict
-INFECTED: JsonDict = {
+SHOP: JsonDict = {
"sword": {
"ok_messages": {
"fr": "Votre achat pour améliorer votre épée a été effectué !",
@@ -129,3 +129,4 @@
},
}
+""" Upgrades sold by this mode's shop, collected by the modes registry. """
diff --git a/src/datapack/modes/laser_game/main.py b/src/datapack/modes/laser_game/main.py
index 656d2576f..8f2069f91 100644
--- a/src/datapack/modes/laser_game/main.py
+++ b/src/datapack/modes/laser_game/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls
+from ..emit import write_modes_calls
from .translations import write_translations
diff --git a/src/datapack/modes/laser_game/translations.py b/src/datapack/modes/laser_game/translations.py
index 0d169ffff..584853bc9 100644
--- a/src/datapack/modes/laser_game/translations.py
+++ b/src/datapack/modes/laser_game/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/layers_2_teams/main.py b/src/datapack/modes/layers_2_teams/main.py
index 721ef1485..e5d87fe22 100644
--- a/src/datapack/modes/layers_2_teams/main.py
+++ b/src/datapack/modes/layers_2_teams/main.py
@@ -2,7 +2,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls
+from ..emit import write_modes_calls
from .translations import write_translations
diff --git a/src/datapack/modes/layers_4_teams/main.py b/src/datapack/modes/layers_4_teams/main.py
index 02a6ad88b..149129ab5 100644
--- a/src/datapack/modes/layers_4_teams/main.py
+++ b/src/datapack/modes/layers_4_teams/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls
+from ..emit import write_modes_calls
from .translations import write_translations
diff --git a/src/datapack/modes/memory_mine/main.py b/src/datapack/modes/memory_mine/main.py
index a8238ee81..849c0437a 100644
--- a/src/datapack/modes/memory_mine/main.py
+++ b/src/datapack/modes/memory_mine/main.py
@@ -2,7 +2,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_no_drop
+from ..emit import write_modes_calls, write_no_drop
from .translations import write_translations
diff --git a/src/datapack/modes/memory_mine/translations.py b/src/datapack/modes/memory_mine/translations.py
index 777293ca2..0b27f4fed 100644
--- a/src/datapack/modes/memory_mine/translations.py
+++ b/src/datapack/modes/memory_mine/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/minigolf/main.py b/src/datapack/modes/minigolf/main.py
index f95f5e70d..31887b1ea 100644
--- a/src/datapack/modes/minigolf/main.py
+++ b/src/datapack/modes/minigolf/main.py
@@ -2,7 +2,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/minigolf/translations.py b/src/datapack/modes/minigolf/translations.py
index ccda3257a..c14cdb33e 100644
--- a/src/datapack/modes/minigolf/translations.py
+++ b/src/datapack/modes/minigolf/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/mlg_a_coudre/main.py b/src/datapack/modes/mlg_a_coudre/main.py
index 3571ede13..0a46ba0a6 100644
--- a/src/datapack/modes/mlg_a_coudre/main.py
+++ b/src/datapack/modes/mlg_a_coudre/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/mlg_a_coudre/translations.py b/src/datapack/modes/mlg_a_coudre/translations.py
index 3cfa60ef2..94137009b 100644
--- a/src/datapack/modes/mlg_a_coudre/translations.py
+++ b/src/datapack/modes/mlg_a_coudre/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/moutron/main.py b/src/datapack/modes/moutron/main.py
index e17bec6a6..eeb188fd2 100644
--- a/src/datapack/modes/moutron/main.py
+++ b/src/datapack/modes/moutron/main.py
@@ -2,7 +2,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls
+from ..emit import write_modes_calls
from .translations import write_translations
diff --git a/src/datapack/modes/moutron/translations.py b/src/datapack/modes/moutron/translations.py
index 5c8b19598..14a36e50a 100644
--- a/src/datapack/modes/moutron/translations.py
+++ b/src/datapack/modes/moutron/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/murder_mystery/main.py b/src/datapack/modes/murder_mystery/main.py
index 1cf39f4ba..ac595c4d5 100644
--- a/src/datapack/modes/murder_mystery/main.py
+++ b/src/datapack/modes/murder_mystery/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_no_drop
+from ..emit import write_modes_calls, write_no_drop
from .translations import write_translations
diff --git a/src/datapack/modes/murder_mystery/translations.py b/src/datapack/modes/murder_mystery/translations.py
index 5b54d09fd..0c51dc183 100644
--- a/src/datapack/modes/murder_mystery/translations.py
+++ b/src/datapack/modes/murder_mystery/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/one_shot/main.py b/src/datapack/modes/one_shot/main.py
index fb435c21a..e1830161f 100644
--- a/src/datapack/modes/one_shot/main.py
+++ b/src/datapack/modes/one_shot/main.py
@@ -1,9 +1,10 @@
+# ruff: noqa: E501
# Imports
from stewbeet import Mem, write_function
from ...kits import Kit, KitItem
-from ..common import write_modes_calls, write_no_drop, write_time_xp_bar
+from ..emit import write_modes_calls, write_no_drop, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/panic_chase/main.py b/src/datapack/modes/panic_chase/main.py
index 358710bcf..1106ec82b 100644
--- a/src/datapack/modes/panic_chase/main.py
+++ b/src/datapack/modes/panic_chase/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_no_drop
+from ..emit import write_modes_calls, write_no_drop
from .translations import write_translations
diff --git a/src/datapack/modes/pillars_of_fortune/main.py b/src/datapack/modes/pillars_of_fortune/main.py
index 774cf0f0d..26810d24a 100644
--- a/src/datapack/modes/pillars_of_fortune/main.py
+++ b/src/datapack/modes/pillars_of_fortune/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/pitch_creep/main.py b/src/datapack/modes/pitch_creep/main.py
index 6e6874d51..1c988f99d 100644
--- a/src/datapack/modes/pitch_creep/main.py
+++ b/src/datapack/modes/pitch_creep/main.py
@@ -3,7 +3,7 @@
from stewbeet import Mem, write_function
from ...kits import Kit, KitItem, Variants
-from ..common import skinned_weapons, write_modes_calls, write_time_xp_bar
+from ..emit import skinned_weapons, write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/pitch_creep/translations.py b/src/datapack/modes/pitch_creep/translations.py
index 37d7469fb..4c7d21019 100644
--- a/src/datapack/modes/pitch_creep/translations.py
+++ b/src/datapack/modes/pitch_creep/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/pitchout/main.py b/src/datapack/modes/pitchout/main.py
index a8808de0c..2299fa3f3 100644
--- a/src/datapack/modes/pitchout/main.py
+++ b/src/datapack/modes/pitchout/main.py
@@ -4,7 +4,7 @@
from stewbeet import Mem, write_function
from ...kits import Kit, KitItem, ScoreCount, Variants
-from ..common import skinned_weapons, write_modes_calls, write_no_drop
+from ..emit import skinned_weapons, write_modes_calls, write_no_drop
from .translations import write_translations
diff --git a/src/datapack/modes/pitchout/shop.py b/src/datapack/modes/pitchout/shop.py
index 7f946ddb0..7d38218ff 100644
--- a/src/datapack/modes/pitchout/shop.py
+++ b/src/datapack/modes/pitchout/shop.py
@@ -2,7 +2,7 @@
# Pitchout shop
from stouputils import JsonDict
-PITCHOUT: JsonDict = {
+SHOP: JsonDict = {
"boots": {
"ok_messages": {
"fr": "Votre achat pour les bottes en cuir a été effectué !",
@@ -46,3 +46,4 @@
},
}
+""" Upgrades sold by this mode's shop, collected by the modes registry. """
diff --git a/src/datapack/modes/pitchout/translations.py b/src/datapack/modes/pitchout/translations.py
index c356bfdd8..9fbb96d1f 100644
--- a/src/datapack/modes/pitchout/translations.py
+++ b/src/datapack/modes/pitchout/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/protect_the_king/main.py b/src/datapack/modes/protect_the_king/main.py
index 4eb6b093f..cdec0a8fa 100644
--- a/src/datapack/modes/protect_the_king/main.py
+++ b/src/datapack/modes/protect_the_king/main.py
@@ -4,7 +4,7 @@
from stewbeet import Mem, write_function
from ...kits import Kit, KitItem
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/pvpswap/main.py b/src/datapack/modes/pvpswap/main.py
index 16459eac4..a58d3c695 100644
--- a/src/datapack/modes/pvpswap/main.py
+++ b/src/datapack/modes/pvpswap/main.py
@@ -4,7 +4,7 @@
from ...kits import Kit
from .._common.main import pvp_arena_kit
-from ..common import write_modes_calls
+from ..emit import write_modes_calls
def write_mode():
diff --git a/src/datapack/modes/race/main.py b/src/datapack/modes/race/main.py
index 6d2ecd1b6..70d691f28 100644
--- a/src/datapack/modes/race/main.py
+++ b/src/datapack/modes/race/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/replicate_the_build/main.py b/src/datapack/modes/replicate_the_build/main.py
index bb15a2080..ed610dd82 100644
--- a/src/datapack/modes/replicate_the_build/main.py
+++ b/src/datapack/modes/replicate_the_build/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls
+from ..emit import write_modes_calls
from .translations import write_translations
diff --git a/src/datapack/modes/replicate_the_build/resources.py b/src/datapack/modes/replicate_the_build/resources.py
index 939ac60e7..4abbc8fbc 100644
--- a/src/datapack/modes/replicate_the_build/resources.py
+++ b/src/datapack/modes/replicate_the_build/resources.py
@@ -1,6 +1,6 @@
# Imports
-from ..common import register_structures
+from ..emit import register_structures
def write_resources() -> None:
diff --git a/src/datapack/modes/rush_the_flag/main.py b/src/datapack/modes/rush_the_flag/main.py
index 0bf4f3fcb..f3a4e5dbc 100644
--- a/src/datapack/modes/rush_the_flag/main.py
+++ b/src/datapack/modes/rush_the_flag/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls
+from ..emit import write_modes_calls
from .translations import write_translations
diff --git a/src/datapack/modes/rush_the_flag/resources.py b/src/datapack/modes/rush_the_flag/resources.py
index 39426f2bb..2cced130c 100644
--- a/src/datapack/modes/rush_the_flag/resources.py
+++ b/src/datapack/modes/rush_the_flag/resources.py
@@ -1,6 +1,6 @@
# Imports
-from ..common import register_break_obsidian_advancement
+from ..emit import register_break_obsidian_advancement
def write_resources() -> None:
diff --git a/src/datapack/modes/rush_the_point/kits.py b/src/datapack/modes/rush_the_point/kits.py
index baf3e2083..09a68de02 100644
--- a/src/datapack/modes/rush_the_point/kits.py
+++ b/src/datapack/modes/rush_the_point/kits.py
@@ -8,6 +8,7 @@
so the kits are built lazily by build_kits() instead of living at module level: the stewbeet CLI
imports this module before Mem.ctx exists.
"""
+# ruff: noqa: E501
# Imports
from stewbeet import Mem
diff --git a/src/datapack/modes/rush_the_point/main.py b/src/datapack/modes/rush_the_point/main.py
index 562c2aa70..8e3ed4cc4 100644
--- a/src/datapack/modes/rush_the_point/main.py
+++ b/src/datapack/modes/rush_the_point/main.py
@@ -4,7 +4,7 @@
from stewbeet import Mem, write_function
from ...kits import Kit
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .kits import build_kits
from .translations import write_translations
diff --git a/src/datapack/modes/rush_the_point/resources.py b/src/datapack/modes/rush_the_point/resources.py
index 2cdda88d6..1ea902b7e 100644
--- a/src/datapack/modes/rush_the_point/resources.py
+++ b/src/datapack/modes/rush_the_point/resources.py
@@ -3,7 +3,7 @@
from beet import Advancement, BlockTag
from stewbeet.core import Mem, set_json_encoder
-from ..common import register_break_obsidian_advancement
+from ..emit import register_break_obsidian_advancement
def write_resources() -> None:
diff --git a/src/datapack/modes/sheepwars/main.py b/src/datapack/modes/sheepwars/main.py
index 215d36d0c..aa0949f57 100644
--- a/src/datapack/modes/sheepwars/main.py
+++ b/src/datapack/modes/sheepwars/main.py
@@ -4,7 +4,7 @@
from stewbeet import Mem, write_function
from ...kits import Kit, KitItem, ScoreCount
-from ..common import write_modes_calls
+from ..emit import write_modes_calls
from .translations import write_translations
diff --git a/src/datapack/modes/sheepwars/shop.py b/src/datapack/modes/sheepwars/shop.py
index c2add867c..955ee1bfa 100644
--- a/src/datapack/modes/sheepwars/shop.py
+++ b/src/datapack/modes/sheepwars/shop.py
@@ -2,7 +2,7 @@
# Sheepwars shop
from stouputils import JsonDict
-SHEEPWARS: JsonDict = {
+SHOP: JsonDict = {
"chosen_kit": {},
"kit_health": {
"ok_messages": {
@@ -153,3 +153,4 @@
},
}
+""" Upgrades sold by this mode's shop, collected by the modes registry. """
diff --git a/src/datapack/modes/shoot_da_sheep/main.py b/src/datapack/modes/shoot_da_sheep/main.py
index 061a6d0f5..dd3192b22 100644
--- a/src/datapack/modes/shoot_da_sheep/main.py
+++ b/src/datapack/modes/shoot_da_sheep/main.py
@@ -2,7 +2,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/shoot_da_sheep/translations.py b/src/datapack/modes/shoot_da_sheep/translations.py
index 968b2d4df..90a86c7b6 100644
--- a/src/datapack/modes/shoot_da_sheep/translations.py
+++ b/src/datapack/modes/shoot_da_sheep/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/simultaneous_jump/main.py b/src/datapack/modes/simultaneous_jump/main.py
index a33269212..353466877 100644
--- a/src/datapack/modes/simultaneous_jump/main.py
+++ b/src/datapack/modes/simultaneous_jump/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/simultaneous_jump/translations.py b/src/datapack/modes/simultaneous_jump/translations.py
index 33b0f2c43..fa5bc3443 100644
--- a/src/datapack/modes/simultaneous_jump/translations.py
+++ b/src/datapack/modes/simultaneous_jump/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/snowball_painter/main.py b/src/datapack/modes/snowball_painter/main.py
index 348e00623..c5c5ecbf3 100644
--- a/src/datapack/modes/snowball_painter/main.py
+++ b/src/datapack/modes/snowball_painter/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/snowball_painter/translations.py b/src/datapack/modes/snowball_painter/translations.py
index 706c17a35..e047e451e 100644
--- a/src/datapack/modes/snowball_painter/translations.py
+++ b/src/datapack/modes/snowball_painter/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/spec.py b/src/datapack/modes/spec.py
new file mode 100644
index 000000000..997567b43
--- /dev/null
+++ b/src/datapack/modes/spec.py
@@ -0,0 +1,117 @@
+""" Typed model of a minigame and of a vote group.
+
+Pure data: no beet, no stewbeet, no Mem. Everything the vote message, the lore and the starting
+pop-ups need is declared here, and definitions.py turns it into storage entries.
+"""
+# Imports
+from dataclasses import dataclass
+
+# Constants
+UNLIMITED: None = None
+""" Value of ModeSpec.max_players when a mode accepts any number of players. """
+
+
+# Classes
+@dataclass(frozen=True, kw_only=True)
+class Localized:
+ """ One short text in both supported languages, split into displayed lines.
+
+ Lines are stored without their trailing newline: the emitter adds it back.
+ """
+
+ fr: tuple[str, ...]
+ """ French lines, ex: ("Survivez en repoussant les creepers", "dans une arène fermée") """
+
+ en: tuple[str, ...]
+ """ English lines, ex: ("Survive by pushing away creepers", "in a closed arena") """
+
+
+@dataclass(frozen=True, kw_only=True)
+class ModeSpec:
+ """ Everything the engine needs to know about one minigame, outside of its logic.
+
+ The id must match the folder name under src/datapack/modes/, which is what the build validates.
+ """
+
+ id: str
+ """ Folder name and storage id, ex: "tnt_run". Frozen forever: the stats objectives embed it. """
+
+ name_fr: str
+ """ Name shown in the vote message, ex: "TNT Run" """
+
+ name_en: str | None = None
+ """ English name when it differs from the French one, otherwise name_fr is used. """
+
+ min_players: int
+ """ Smallest player count that allows the mode to be voted. """
+
+ max_players: int | None = UNLIMITED
+ """ Largest player count, or UNLIMITED when the mode has no upper bound. """
+
+ group: str | None = None
+ """ Vote group this mode belongs to, or None when the mode is its own group. """
+
+ estimated_time: str
+ """ Human readable duration shown in the lore, ex: "1-4 mins" """
+
+ inspiration: str
+ """ Where the concept comes from, ex: "Grian (2013)" """
+
+ suggested_by: str
+ """ Who proposed the mode. """
+
+ developed_by: str
+ """ Who implemented the mode. """
+
+ description: Localized
+ """ Two or three lines pitching the mode, shown in the vote lore. """
+
+ @property
+ def vote_group(self) -> str:
+ """ The group this mode is voted under, defaulting to its own id.
+
+ Returns:
+ str: The group id
+ """
+ return self.group if self.group is not None else self.id
+
+ def display_name(self, lang: str) -> str:
+ """ The mode name in the given language, falling back to French.
+
+ Args:
+ lang (str): Language key, ex: "fr" or "en"
+ Returns:
+ str: The localized name
+ """
+ return self.name_en if lang == "en" and self.name_en is not None else self.name_fr
+
+
+@dataclass(frozen=True, kw_only=True)
+class GroupSpec:
+ """ A vote entry gathering several minigames, shown before the second vote picks one. """
+
+ id: str
+ """ Group id, referenced by ModeSpec.group, ex: "layers" """
+
+ name_fr: str
+ """ Name shown in the vote message, ex: "Layers" """
+
+ name_en: str | None = None
+ """ English name when it differs from the French one, otherwise name_fr is used. """
+
+ estimated_time: str
+ """ Human readable duration covering every mode of the group, ex: "4-10 mins" """
+
+ description: Localized
+ """ Two lines pitching what the group has in common. """
+
+ def display_name(self, lang: str) -> str:
+ """ The group name in the given language, falling back to French.
+
+ Args:
+ lang (str): Language key, ex: "fr" or "en"
+ Returns:
+ str: The localized name
+ """
+ return self.name_en if lang == "en" and self.name_en is not None else self.name_fr
+
diff --git a/src/datapack/modes/spectres_game/main.py b/src/datapack/modes/spectres_game/main.py
index b9af26e25..887012c01 100644
--- a/src/datapack/modes/spectres_game/main.py
+++ b/src/datapack/modes/spectres_game/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .kits import spectre_kit, visible_kit
from .translations import write_translations
diff --git a/src/datapack/modes/spectres_game/shop.py b/src/datapack/modes/spectres_game/shop.py
index 5421ef785..a1deb7dd6 100644
--- a/src/datapack/modes/spectres_game/shop.py
+++ b/src/datapack/modes/spectres_game/shop.py
@@ -9,7 +9,7 @@
{"price": 150, "hover_text": {"fr": "5 -> 6 TNT", "en": "5 -> 6 TNT"}},
]
-SPECTRES_GAME: JsonDict = {
+SHOP: JsonDict = {
"sp_arrows": {
"ok_messages": {
"fr": "Votre achat pour augmenter le nombre de fleches pour votre classe Spectre a été effectué !",
@@ -95,3 +95,4 @@
},
}
+""" Upgrades sold by this mode's shop, collected by the modes registry. """
diff --git a/src/datapack/modes/spleef/main.py b/src/datapack/modes/spleef/main.py
index b670a7442..9087e8ad8 100644
--- a/src/datapack/modes/spleef/main.py
+++ b/src/datapack/modes/spleef/main.py
@@ -4,7 +4,7 @@
from stewbeet import Mem, write_function
from ...kits import Kit, KitItem, ScoreCount
-from ..common import write_modes_calls
+from ..emit import write_modes_calls
from .translations import write_translations
diff --git a/src/datapack/modes/spleef/shop.py b/src/datapack/modes/spleef/shop.py
index 2c0408df0..4af09ffe8 100644
--- a/src/datapack/modes/spleef/shop.py
+++ b/src/datapack/modes/spleef/shop.py
@@ -2,7 +2,7 @@
# Spleef shop
from stouputils import JsonDict
-SPLEEF: JsonDict = {
+SHOP: JsonDict = {
"snow_block": {
"ok_messages": {
"fr": "Votre achat pour augmenter votre nombre de blocs de neige a été effectué !",
@@ -49,3 +49,4 @@
},
}
+""" Upgrades sold by this mode's shop, collected by the modes registry. """
diff --git a/src/datapack/modes/spleef/translations.py b/src/datapack/modes/spleef/translations.py
index df7d69d40..c6096f1b9 100644
--- a/src/datapack/modes/spleef/translations.py
+++ b/src/datapack/modes/spleef/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/thunder_spear/main.py b/src/datapack/modes/thunder_spear/main.py
index e0fa828fb..2bfff7462 100644
--- a/src/datapack/modes/thunder_spear/main.py
+++ b/src/datapack/modes/thunder_spear/main.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/thunder_spear/translations.py b/src/datapack/modes/thunder_spear/translations.py
index 222618ebf..9fd95959b 100644
--- a/src/datapack/modes/thunder_spear/translations.py
+++ b/src/datapack/modes/thunder_spear/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/tnt_run/main.py b/src/datapack/modes/tnt_run/main.py
index 14e28e20e..f475ba275 100644
--- a/src/datapack/modes/tnt_run/main.py
+++ b/src/datapack/modes/tnt_run/main.py
@@ -2,7 +2,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/tnt_run/translations.py b/src/datapack/modes/tnt_run/translations.py
index 54616e0ea..9760c0fc0 100644
--- a/src/datapack/modes/tnt_run/translations.py
+++ b/src/datapack/modes/tnt_run/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/modes/traitors_game/main.py b/src/datapack/modes/traitors_game/main.py
index c50183fdd..eb79079f5 100644
--- a/src/datapack/modes/traitors_game/main.py
+++ b/src/datapack/modes/traitors_game/main.py
@@ -4,7 +4,7 @@
from stewbeet import Mem, write_function
from ...kits import Kit, KitItem, Variants
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/warden_escape/main.py b/src/datapack/modes/warden_escape/main.py
index 5f504e98c..73a017e78 100644
--- a/src/datapack/modes/warden_escape/main.py
+++ b/src/datapack/modes/warden_escape/main.py
@@ -2,7 +2,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_modes_calls, write_time_xp_bar
+from ..emit import write_modes_calls, write_time_xp_bar
from .translations import write_translations
diff --git a/src/datapack/modes/warden_escape/resources.py b/src/datapack/modes/warden_escape/resources.py
index 1cc341497..b945afddd 100644
--- a/src/datapack/modes/warden_escape/resources.py
+++ b/src/datapack/modes/warden_escape/resources.py
@@ -3,7 +3,7 @@
from beet import Advancement
from stewbeet.core import Mem, set_json_encoder
-from ..common import register_sounds
+from ..emit import register_sounds
def write_resources() -> None:
diff --git a/src/datapack/modes/warden_escape/translations.py b/src/datapack/modes/warden_escape/translations.py
index 7884ac292..e56b6bf1f 100644
--- a/src/datapack/modes/warden_escape/translations.py
+++ b/src/datapack/modes/warden_escape/translations.py
@@ -3,7 +3,7 @@
# Imports
from stewbeet import Mem, write_function
-from ..common import write_server_announce
+from ..emit import write_server_announce
def write_translations():
diff --git a/src/datapack/shop/main.py b/src/datapack/shop/main.py
index aed28e795..62c90f616 100644
--- a/src/datapack/shop/main.py
+++ b/src/datapack/shop/main.py
@@ -1,6 +1,6 @@
# Imports
-from .shared_memory import SHOPS
+from .shared_memory import ordered_shops
from .utils import (
general_translations,
generate_shop,
@@ -18,7 +18,7 @@ def main() -> None:
write_raw_functions()
# Generate all the shops
- for i, (shop_name, shop_dict) in enumerate(SHOPS.items()):
+ for i, (shop_name, shop_dict) in enumerate(ordered_shops().items()):
generate_shop(i, shop_name, shop_dict)
# Write the trigger function
diff --git a/src/datapack/shop/shared_memory.py b/src/datapack/shop/shared_memory.py
index 5e210c8f2..22bf54b1f 100644
--- a/src/datapack/shop/shared_memory.py
+++ b/src/datapack/shop/shared_memory.py
@@ -2,11 +2,7 @@
# Imports
from stewbeet import JsonDict, Mem
-from ..modes.infected.shop import INFECTED
-from ..modes.pitchout.shop import PITCHOUT
-from ..modes.sheepwars.shop import SHEEPWARS
-from ..modes.spectres_game.shop import SPECTRES_GAME
-from ..modes.spleef.shop import SPLEEF
+from ..modes import MODE_SHOPS
# Constants
STAR: str = "✮"
@@ -27,13 +23,22 @@ def get_money() -> dict[str, JsonDict]:
"en": "Select a kit by clicking on the name!"
}
SHEEPWARS_KIT_OFFSET: int = 50
-SHOPS: dict[str, dict[str, JsonDict]] = {
- "pitchout": PITCHOUT,
- "infected": INFECTED,
- "spleef": SPLEEF,
- "sheepwars": SHEEPWARS,
- "spectres_game": SPECTRES_GAME,
-}
+SHOP_ORDER: tuple[str, ...] = ("pitchout", "infected", "spleef", "sheepwars", "spectres_game")
+""" Frozen display order of the minigame shops.
+
+A shop's rank drives its trigger score range (see get_shop_range), which is what a player's click
+sends, so reordering this changes the generated commands. A shop missing here is appended last.
+"""
+
+def ordered_shops() -> dict[str, dict[str, JsonDict]]:
+ """ Every mode shop, in display order.
+
+ Returns:
+ dict[str, dict[str, JsonDict]]: Mode id -> its upgrades
+ """
+ listed: list[str] = [name for name in SHOP_ORDER if name in MODE_SHOPS]
+ return {name: MODE_SHOPS[name] for name in listed + [name for name in MODE_SHOPS if name not in SHOP_ORDER]}
+
# Function paths, relative to the project namespace (e.g. f"{ns}:{LOAD_PATH}")
LOAD_PATH: str = "shop/_load"
TRIGGER_PATH: str = "shop/trigger"
diff --git a/src/datapack/shop/utils.py b/src/datapack/shop/utils.py
index 31a59aa8b..bd4e5693e 100644
--- a/src/datapack/shop/utils.py
+++ b/src/datapack/shop/utils.py
@@ -13,12 +13,12 @@
REFUND_PERCENTAGE,
SHEEPWARS_CHOOSE_KIT,
SHEEPWARS_KIT_OFFSET,
- SHOPS,
STAR,
TRIGGER_PATH,
USERNAME_CHANGE_PATH,
get_money,
get_shop_range,
+ ordered_shops,
)
@@ -309,7 +309,7 @@ def generate_trigger() -> None:
# Minigames shops
""")
- for i, (shop_name, _) in enumerate(SHOPS.items()):
+ for i, (shop_name, _) in enumerate(ordered_shops().items()):
mini, maxi = get_shop_range(i)
write_function(f"{ns}:{TRIGGER_PATH}", f"execute if score @s {ns}.trigger.shop matches {mini}..{maxi} run function {ns}:shop/{shop_name}")
@@ -340,7 +340,7 @@ def general_translations() -> None:
tellraw {selector} [{{"text":"[","color":"#1b1796"}},{{"text":"{label.replace('X', 'Switch')}","color":"blue"}},{{"text":"]","color":"#1b1796"}},{{"text":" - ","color":"blue"}},{{"score":{{"name":"@s","objective":"{ns}.money"}},"color":"blue","underlined":true}},{json.dumps(get_money()[lang_id])},{{"text":"\\n"}}]
""")
# For each shop, write the access text
- for i, shop_name in enumerate(SHOPS.keys()):
+ for i, shop_name in enumerate(ordered_shops()):
mini: int = get_shop_range(i)[0]
titled: str = shop_name.replace("_", " ").title()
write_function(path, f"""tellraw {selector} [{{"text":"➤ ","color":"#1b1796","click_event":{{"action":"run_command","command":"/trigger {ns}.trigger.shop set {mini}"}}, "hover_event":{{"action":"show_text","value":{{"text":"{access_text.replace('X', titled)}","color":"gray"}}}}}},{{"text":"[","color":"#1b1796"}},{{"text":"{titled}","color":"blue"}},{{"text":"]","color":"#1b1796"}}]""")
diff --git a/src/datapack/survival_maps/utils.py b/src/datapack/survival_maps/utils.py
index 0bc4b81b7..bb5d3fd41 100644
--- a/src/datapack/survival_maps/utils.py
+++ b/src/datapack/survival_maps/utils.py
@@ -8,6 +8,7 @@
from beet import Function
from stewbeet.core import Mem, write_function
+from ..modes.catalogue import MODES
from .shared_memory import SharedMemory, generated_maps, survival_maps
@@ -784,16 +785,14 @@ def generate_map_usage_file() -> None:
config (dict): The configuration of the project
"""
ns: str = Mem.ctx.project_id
- from ..modes.definitions import MODES
path: str = f"{Mem.ctx.directory}/map_usage.json"
CHOOSE_MAP_FOR: str = f"function {ns}:utils/choose_map_for"
# Get for each modes the maps they use
modes_usage: dict[str, list[str]] = {}
for mode in MODES:
- mode_id: str = mode["id"]
- start_file: str = f"{ns}:modes/{mode_id}/"
- modes_usage[mode_id] = []
+ start_file: str = f"{ns}:modes/{mode.id}/"
+ modes_usage[mode.id] = []
# Get the start function of the mode and search for "function switch:utils/choose_map_for {id:"block_party", maps:["pitch_creep_1","octogone_nether_ice"]}"
function_content: str = Mem.ctx.data.functions.get(start_file + "start_common", Function()).text.strip()
@@ -805,7 +804,7 @@ def generate_map_usage_file() -> None:
splitted: list[str] = function_content.split(CHOOSE_MAP_FOR)
if len(splitted) > 1:
maps_str: str = splitted[1].split("\n")[0].split("maps:")[1].split("}")[0]
- modes_usage[mode_id] = json.loads(maps_str)
+ modes_usage[mode.id] = json.loads(maps_str)
# Now, generate the maps_usage dictionary
maps_usage: dict[str, list[str]] = {}
diff --git a/src/link.py b/src/link.py
index cdcae7fcc..8b4883b8b 100644
--- a/src/link.py
+++ b/src/link.py
@@ -23,6 +23,7 @@
from .datapack.translations.main import main as shared_translations
from .datapack.utils.main import main as utils
from .resource_pack.special_fonts.main import main as special_fonts
+from .validation import Validation
# Main function is run just before making finalyzing the build process (zip, headers, lang, ...)
@@ -63,6 +64,9 @@ def beet_default(ctx: Context) -> None:
shop()
special_fonts()
+ # Fail the build on a mode, group or map declaration that would only break in game
+ Validation.run()
+
# Force enable dump module from Bookshelf (https://docs.mcbookshelf.dev/en/latest/modules/dump.html)
official_lib_used("bs.dump")
diff --git a/src/validation.py b/src/validation.py
new file mode 100644
index 000000000..c339dbba0
--- /dev/null
+++ b/src/validation.py
@@ -0,0 +1,189 @@
+""" Build-time validation of the project's declarative data.
+
+Catches at build time what used to only show up in game: a mode without its folder, a group that
+nobody declares, a map id with a typo. Every message names the offending declaration and, when it
+can, the closest valid value.
+"""
+# Imports
+import json
+import re
+from dataclasses import dataclass
+from difflib import get_close_matches
+from pathlib import Path
+
+import stouputils as stp
+from stewbeet import Mem
+
+from .datapack.modes.catalogue import GROUPS, GROUPS_BY_ID, MODES
+from .datapack.modes.definitions import grouped_modes
+from .datapack.survival_maps.shared_memory import generated_maps
+
+# Constants
+MODES_FOLDER: Path = Path(__file__).parent / "datapack" / "modes"
+""" Folder holding one package per minigame, used to check that every spec has its code. """
+
+RESERVED_FOLDERS: frozenset[str] = frozenset({"_common", "_coupdetat"})
+""" Mode folders that are helpers for the engine, never minigames of their own. """
+
+MAPS_OUTSIDE_REGISTRY: frozenset[str] = frozenset({"build_battle", "kart_racer_relai"})
+""" Maps that live in the world instead of being generated by the survival map pipeline.
+
+They are the two maps without an intro cinematic, see the note in switch:engine/signals/second.
+"""
+
+CHOOSE_MAP_PATTERN: re.Pattern[str] = re.compile(r"choose_map_for\s*\{[^}]*maps:(\[[^\]]*\])")
+""" Captures the maps list of a `function switch:utils/choose_map_for {id:"x", maps:[...]}` call. """
+
+MAP_CONDITION_PATTERN: re.Pattern[str] = re.compile(r'\{map:"([a-z0-9_]+)"\}')
+""" Captures the map id of a `if data storage switch:main {map:"x"}` condition. """
+
+
+# Classes
+@dataclass(frozen=True)
+class Problem:
+ """ One validation failure, rendered as a single actionable line. """
+
+ message: str
+ """ What is wrong, naming the offending declaration. """
+
+ hint: str = ""
+ """ Optional suggestion, ex: the closest valid map id. """
+
+ def render(self) -> str:
+ """ The message with its hint appended when there is one.
+
+ Returns:
+ str: The line to print
+
+ Examples:
+ >>> Problem("mode 'x' has no folder").render()
+ "mode 'x' has no folder"
+ >>> Problem("unknown map 'tnt_runn'", "did you mean 'tnt_run'?").render()
+ "unknown map 'tnt_runn' (did you mean 'tnt_run'?)"
+ """
+ return f"{self.message} ({self.hint})" if self.hint else self.message
+
+
+class Validation:
+
+ @staticmethod
+ def suggest(value: str, candidates: list[str]) -> str:
+ """ A "did you mean" hint for a value that was not found.
+
+ Args:
+ value (str): The offending value, ex: "tnt_runn"
+ candidates (list[str]): Every valid value
+ Returns:
+ str: The hint, empty when nothing is close enough
+
+ Examples:
+ >>> Validation.suggest("tnt_runn", ["tnt_run", "spleef"])
+ "did you mean 'tnt_run'?"
+ >>> Validation.suggest("zzzzz", ["tnt_run"])
+ ''
+ """
+ close: list[str] = get_close_matches(value, candidates, n=1)
+ return f"did you mean '{close[0]}'?" if close else ""
+
+ @staticmethod
+ def declared_folders() -> set[str]:
+ """ Mode folders holding a main.py, excluding the engine helpers.
+
+ Returns:
+ set[str]: Folder names, ex: {"tnt_run", "spleef"}
+ """
+ return {entry.name for entry in MODES_FOLDER.iterdir()
+ if entry.is_dir() and entry.name not in RESERVED_FOLDERS and (entry / "main.py").is_file()}
+
+ @staticmethod
+ def check_modes() -> list[Problem]:
+ """ Every mode has a unique id, a folder, a sane player range and a declared group.
+
+ Returns:
+ list[Problem]: The failures found
+ """
+ problems: list[Problem] = []
+ folders: set[str] = Validation.declared_folders()
+ seen: set[str] = set()
+
+ for mode in MODES:
+ if mode.id in seen:
+ problems.append(Problem(f"mode '{mode.id}' is declared twice in MODES"))
+ seen.add(mode.id)
+
+ if mode.id not in folders:
+ problems.append(Problem(
+ f"mode '{mode.id}' has no folder src/datapack/modes/{mode.id}/main.py",
+ Validation.suggest(mode.id, sorted(folders))))
+
+ if mode.min_players < 1:
+ problems.append(Problem(f"mode '{mode.id}' has min_players={mode.min_players}, expected 1 or more"))
+ if mode.max_players is not None and mode.max_players < mode.min_players:
+ problems.append(Problem(
+ f"mode '{mode.id}' has max_players={mode.max_players} below min_players={mode.min_players}"))
+
+ for group_id, modes in grouped_modes().items():
+ if len(modes) > 1 and group_id not in GROUPS_BY_ID:
+ problems.append(Problem(
+ f"group '{group_id}' gathers {len(modes)} modes but is missing from GROUPS",
+ Validation.suggest(group_id, sorted(GROUPS_BY_ID))))
+ return problems
+
+ @staticmethod
+ def referenced_maps() -> dict[str, set[str]]:
+ """ Every map id referenced by a generated function, mapped to the functions using it.
+
+ Returns:
+ dict[str, set[str]]: Map id -> function paths
+ """
+ referenced: dict[str, set[str]] = {}
+ for path, function in Mem.ctx.data.functions.items():
+ text: str = function.text
+ names: list[str] = MAP_CONDITION_PATTERN.findall(text)
+ for raw in CHOOSE_MAP_PATTERN.findall(text):
+ names.extend(json.loads(raw))
+ for name in names:
+ referenced.setdefault(name, set()).add(path)
+ return referenced
+
+ @staticmethod
+ def check_maps() -> list[Problem]:
+ """ Every referenced map is either generated or a known world map.
+
+ Returns:
+ list[Problem]: The failures found
+ """
+ known: set[str] = set(generated_maps) | MAPS_OUTSIDE_REGISTRY
+ problems: list[Problem] = []
+ for name, paths in sorted(Validation.referenced_maps().items()):
+ if name not in known:
+ problems.append(Problem(
+ f"unknown map '{name}' referenced by {sorted(paths)[0]}",
+ Validation.suggest(name, sorted(generated_maps))))
+ return problems
+
+ @staticmethod
+ def report_unvoted() -> None:
+ """ Log the modes that generate but never reach the vote, and the groups nobody uses. """
+ unvoted: list[str] = sorted(Validation.declared_folders() - {mode.id for mode in MODES})
+ if unvoted:
+ stp.info(f"{len(unvoted)} mode(s) generated but absent from the vote: {', '.join(unvoted)}")
+
+ used: set[str] = {group_id for group_id, modes in grouped_modes().items() if len(modes) > 1}
+ unused: list[str] = sorted(group.id for group in GROUPS if group.id not in used)
+ if unused:
+ stp.warning(f"{len(unused)} group(s) declared but unused: {', '.join(unused)}")
+
+ @staticmethod
+ def run() -> None:
+ """ Run every check, then stop the build when something is wrong.
+
+ Raises:
+ ValueError: When at least one check failed
+ """
+ problems: list[Problem] = Validation.check_modes() + Validation.check_maps()
+ Validation.report_unvoted()
+ if problems:
+ listed: str = "\n".join(f" {problem.render()}" for problem in problems)
+ raise ValueError(f"{len(problems)} declaration problem(s):\n{listed}")
+
diff --git a/tools/check_conventions.py b/tools/check_conventions.py
new file mode 100644
index 000000000..fb702886a
--- /dev/null
+++ b/tools/check_conventions.py
@@ -0,0 +1,158 @@
+""" Enforce the architectural rules that ruff and pyright cannot express.
+
+Static only: reads the sources, never builds. The build based guards live in
+check_output_drift.py and report_merged_functions.py.
+"""
+# Imports
+import ast
+import re
+import sys
+from pathlib import Path
+
+import stouputils as stp
+
+# Constants
+SRC: Path = Path(__file__).resolve().parent.parent / "src"
+""" Root of the project sources. """
+
+MODES: Path = SRC / "datapack" / "modes"
+""" Folder holding one package per minigame. """
+
+MAX_LINES: int = 300
+""" A file past this length becomes a package, so that finding things stays a matter of folders. """
+
+LONG_FILE_EXCEPTIONS: tuple[str, ...] = ("datapack/modes/*/main.py", "datapack/modes/catalogue.py")
+""" Files allowed to grow past MAX_LINES, as posix glob patterns relative to src/.
+
+Both are single tables read with Ctrl+F: a mode's main.py holds all of that game's logic, and the
+catalogue holds every mode declaration. Splitting either would only add folders to search through.
+"""
+
+LONG_FILE_DEBT: frozenset[str] = frozenset({
+ "datapack/cinematic/main.py",
+ "datapack/engine/main.py",
+ "datapack/kits/model.py",
+ "datapack/maps/main.py",
+ "datapack/player/jump_timer.py",
+ "datapack/player/layout.py",
+ "datapack/player/main.py",
+ "datapack/player/practice.py",
+ "datapack/player/translations.py",
+ "datapack/root/main.py",
+ "datapack/shop/utils.py",
+ "datapack/stats/main.py",
+ "datapack/survival_maps/utils.py",
+ "resource_pack/shaders.py",
+})
+""" Files already past MAX_LINES, to be split into packages.
+
+This ledger may only shrink: a file leaving it must be removed from here, and no file may be
+added. It exists so the rule can be enforced today instead of after the whole split is done.
+"""
+
+MODEL_FILES: tuple[str, ...] = ("datapack/modes/spec.py",)
+""" Files that describe the domain and must stay free of untyped containers. """
+
+UNTYPED_NAMES: frozenset[str] = frozenset({"Any", "JsonDict", "JsonList"})
+""" Containers that defeat the type checker, banned from the model files. """
+
+DOWNWARD_IMPORT: re.Pattern[str] = re.compile(r"^\s*from\s+\.+modes\.([a-z0-9_]+)\.", re.MULTILINE)
+""" Import of a specific mode from outside the modes package, ex: `from ..modes.spleef.shop import`. """
+
+
+# Classes
+class Conventions:
+
+ @staticmethod
+ def python_files() -> list[Path]:
+ """ Every source file of the project.
+
+ Returns:
+ list[Path]: Paths to each .py file under src/
+ """
+ return sorted(path for path in SRC.rglob("*.py") if "__pycache__" not in path.parts)
+
+ @staticmethod
+ def relative(path: Path) -> str:
+ """ Path of a source file relative to src/, with forward slashes.
+
+ Args:
+ path (Path): The file
+ Returns:
+ str: The relative path, ex: "datapack/modes/tnt_run/main.py"
+ """
+ return path.relative_to(SRC).as_posix()
+
+ @staticmethod
+ def check_length() -> list[str]:
+ """ No file grows past MAX_LINES, outside of the documented exceptions.
+
+ Returns:
+ list[str]: One message per offending file
+ """
+ problems: list[str] = []
+ for path in Conventions.python_files():
+ relative: str = Conventions.relative(path)
+ if any(Path(relative).match(pattern) for pattern in LONG_FILE_EXCEPTIONS):
+ continue
+ length: int = len(path.read_text(encoding="utf-8").splitlines())
+ if length > MAX_LINES and relative not in LONG_FILE_DEBT:
+ problems.append(f"{relative} is {length} lines, split it into a package (limit {MAX_LINES})")
+ elif length <= MAX_LINES and relative in LONG_FILE_DEBT:
+ problems.append(f"{relative} is now {length} lines, remove it from LONG_FILE_DEBT")
+ return problems
+
+ @staticmethod
+ def check_model_purity() -> list[str]:
+ """ The model files describe data with real types, never with untyped containers.
+
+ Returns:
+ list[str]: One message per offending name
+ """
+ problems: list[str] = []
+ for relative in MODEL_FILES:
+ source: str = (SRC / relative).read_text(encoding="utf-8")
+ for node in ast.walk(ast.parse(source)):
+ if isinstance(node, ast.Name) and node.id in UNTYPED_NAMES:
+ problems.append(f"{relative}:{node.lineno} uses '{node.id}', the model must stay fully typed")
+ return problems
+
+ @staticmethod
+ def check_no_downward_import() -> list[str]:
+ """ No subsystem imports a named minigame: it consumes the registry instead.
+
+ Returns:
+ list[str]: One message per offending import
+ """
+ problems: list[str] = []
+ for path in Conventions.python_files():
+ if MODES in path.parents:
+ continue
+ for mode in DOWNWARD_IMPORT.findall(path.read_text(encoding="utf-8")):
+ problems.append(
+ f"{Conventions.relative(path)} imports mode '{mode}' directly, "
+ f"consume the registry so adding a mode stays a one folder change")
+ return problems
+
+ @staticmethod
+ def main() -> int:
+ """ Run every convention check.
+
+ Returns:
+ int: Process exit code, 0 when the sources follow the rules
+ """
+ problems: list[str] = (
+ Conventions.check_length() + Conventions.check_model_purity() + Conventions.check_no_downward_import())
+ if not problems:
+ stp.info(f"{len(Conventions.python_files())} source file(s) follow the conventions")
+ return 0
+
+ stp.error(f"{len(problems)} convention problem(s):")
+ for problem in problems:
+ stp.error(f" {problem}")
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(Conventions.main())
+
diff --git a/tools/check_output_drift.py b/tools/check_output_drift.py
new file mode 100644
index 000000000..434046f99
--- /dev/null
+++ b/tools/check_output_drift.py
@@ -0,0 +1,90 @@
+""" Golden-file guard: rebuild the project and fail if the tracked build/ output moved.
+
+Any purely structural refactoring must leave the generated output byte for byte identical.
+Run this after moving code around: a non-empty diff means the refactor changed behaviour.
+"""
+# Imports
+import subprocess
+import sys
+from pathlib import Path
+
+import stouputils as stp
+
+# Constants
+ROOT: Path = Path(__file__).resolve().parent.parent
+""" Repository root, the working directory every command below runs from. """
+
+OUTPUT_FOLDER: str = "build"
+""" Tracked folder holding the generated datapack and resource pack. """
+
+MAX_LISTED_FILES: int = 25
+""" How many drifted paths to print before summarizing the rest. """
+
+
+# Classes
+class OutputDrift:
+
+ @staticmethod
+ def run(*args: str) -> subprocess.CompletedProcess[str]:
+ """ Run a command from the repository root and capture its output.
+
+ Args:
+ *args (str): Command and its arguments, ex: "git", "status"
+ Returns:
+ subprocess.CompletedProcess[str]: The completed process, never raising on a non-zero code
+ """
+ return subprocess.run(args, cwd=ROOT, capture_output=True, text=True, check=False)
+
+ @staticmethod
+ def drifted_paths() -> list[str]:
+ """ List every tracked or untracked path under the output folder that differs from HEAD.
+
+ Returns:
+ list[str]: Porcelain paths, ex: ["build/datapack/data/switch/function/tick.mcfunction"]
+ """
+ status: str = OutputDrift.run("git", "status", "--porcelain", "--", OUTPUT_FOLDER).stdout
+ return [line[3:].strip().strip('"') for line in status.splitlines() if line.strip()]
+
+ @staticmethod
+ def build() -> bool:
+ """ Rebuild the project with the StewBeet CLI.
+
+ Returns:
+ bool: Whether the build succeeded
+ """
+ build: subprocess.CompletedProcess[str] = OutputDrift.run("stewbeet")
+ if build.returncode != 0:
+ stp.error(f"Build failed with exit code {build.returncode}:\n{build.stdout}\n{build.stderr}")
+ return build.returncode == 0
+
+ @staticmethod
+ def main() -> int:
+ """ Rebuild, then compare the output folder against HEAD.
+
+ Returns:
+ int: Process exit code, 0 when the output is unchanged
+ """
+ # A dirty output folder before building makes the comparison meaningless
+ if OutputDrift.drifted_paths():
+ stp.error(f"'{OUTPUT_FOLDER}/' already differs from HEAD before building, commit or restore it first")
+ return 1
+
+ if not OutputDrift.build():
+ return 1
+
+ drifted: list[str] = OutputDrift.drifted_paths()
+ if not drifted:
+ stp.info(f"No drift: '{OUTPUT_FOLDER}/' is identical to HEAD")
+ return 0
+
+ stp.error(f"{len(drifted)} file(s) drifted in '{OUTPUT_FOLDER}/':")
+ for path in drifted[:MAX_LISTED_FILES]:
+ stp.error(f" {path}")
+ if len(drifted) > MAX_LISTED_FILES:
+ stp.error(f" ... and {len(drifted) - MAX_LISTED_FILES} more")
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(OutputDrift.main())
+
diff --git a/tools/new_mode.py b/tools/new_mode.py
new file mode 100644
index 000000000..182263042
--- /dev/null
+++ b/tools/new_mode.py
@@ -0,0 +1,213 @@
+""" Scaffold a new minigame: creates its package and prints the MODES entry to paste.
+
+The generated mode builds and is playable right away through its _force_start, so a contributor
+can see something in game before reading anything about the pipeline.
+"""
+# Imports
+import re
+import sys
+from pathlib import Path
+
+import stouputils as stp
+
+# Constants
+MODES_FOLDER: Path = Path(__file__).resolve().parent.parent / "src" / "datapack" / "modes"
+""" Folder receiving one package per minigame. """
+
+VALID_ID: re.Pattern[str] = re.compile(r"^[a-z][a-z0-9_]*$")
+""" A mode id is a lowercase python identifier: it is both a folder name and a storage key. """
+
+MAIN_TEMPLATE: str = '''
+# Imports
+from stewbeet import Mem, write_function
+
+from ..emit import write_modes_calls, write_time_xp_bar
+from .translations import write_translations
+
+DURATION: int = 300
+""" How long a game lasts, in seconds. """
+
+
+def write_mode():
+ ns: str = Mem.ctx.project_id
+ mode: str = "__MODE__"
+ path: str = f"{ns}:modes/{mode}"
+
+ # Write /calls/ (the dispatch the engine goes through) and /translations/
+ write_modes_calls(mode)
+ write_translations()
+
+ # /death (called when a player dies: turn them into a spectator)
+ write_function(f"{path}/death", f"""
+function {path}/translations/death
+function {ns}:modes/_common/death_spectator
+""")
+
+ # /joined (a player joining a running game only gets to watch)
+ write_function(f"{path}/joined", f"""
+gamemode spectator @s
+function {path}/death
+""")
+
+ # /process_end (end the game once a single player is left standing)
+ write_function(f"{path}/process_end", f"""
+function {ns}:modes/_common/process_end/last_survivor {{death:"{path}/death"}}
+""")
+
+ # /start (once, when the game begins: pick a map and reset the counters)
+ write_function(f"{path}/start", f"""
+effect give @a[tag=!detached] saturation infinite 255 true
+
+# Pick the arena among the maps declared in survival_maps/definitions.py
+scoreboard players set #do_spreadplayers {ns}.data 1
+function {ns}:utils/choose_map_for {{id:"{mode}", maps:["switch_space"]}}
+
+function {path}/translations/start
+
+scoreboard players set #__MODE___seconds {ns}.data -6
+scoreboard players set #process_end {ns}.data 0
+""")
+
+ # /second (once per game second: countdown, then the time limit)
+ write_function(f"{path}/second", f"""
+scoreboard players add #__MODE___seconds {ns}.data 1
+function {path}/xp_bar
+
+execute if score #__MODE___seconds {ns}.data matches -5..0 as @a[tag=!detached] at @s run playsound entity.experience_orb.pickup ambient @s
+function {path}/translations/second
+
+execute if score #__MODE___seconds {ns}.data matches {DURATION}.. run function {path}/process_end
+""")
+
+ # /tick (every game tick: catch deaths and check whether the game is over)
+ write_function(f"{path}/tick", f"""
+function {ns}:utils/on_death_run_function {{function:"{path}/death"}}
+
+scoreboard players set #remaining_players {ns}.data 0
+execute store result score #remaining_players {ns}.data if entity @a[tag=!detached,gamemode=!spectator]
+execute if score #__MODE___seconds {ns}.data matches 1.. if score #remaining_players {ns}.data matches ..1 run function {path}/process_end
+""")
+
+ # /stop (clean up everything /start created)
+ write_function(f"{path}/stop", f"""
+scoreboard players reset #__MODE___seconds {ns}.data
+""")
+
+ # /xp_bar (the XP bar doubles as the remaining time gauge)
+ write_time_xp_bar(f"{path}/xp_bar", DURATION, "#__MODE___seconds", "#__MODE___seconds")
+
+'''
+""" Body of a new mode's main.py, with __MODE__ standing for its id. """
+
+TRANSLATIONS_TEMPLATE: str = '''
+# Imports
+from stewbeet import Mem, write_function
+
+from ..emit import write_server_announce
+
+
+def write_translations():
+ """ Write the __MODE__ translation functions at switch:modes/__MODE__/translations/* """
+ ns: str = Mem.ctx.project_id
+ path: str = f"{ns}:modes/__MODE__/translations"
+
+ # /death
+ write_function(f"{path}/death", f"""
+# French
+execute if entity @s[gamemode=!spectator] run tellraw @a[scores={{{ns}.lang=0}},tag=!detached] [{{"selector":"@s","color":"red"}},{{"text":" est mort !"}}]
+
+# English
+execute if entity @s[gamemode=!spectator] run tellraw @a[scores={{{ns}.lang=1}},tag=!detached] [{{"selector":"@s","color":"red"}},{{"text":" died!"}}]
+""")
+
+ # /second (the 5 second countdown before the game starts)
+ write_function(f"{path}/second", "\\n".join(
+ f"""execute if score #__MODE___seconds {ns}.data matches {-second} run title @a[scores={{{ns}.lang={lang}}},tag=!detached] title {{"text":"{second}","color":"red"}}"""
+ for lang in (0, 1) for second in (5, 4, 3, 2, 1)
+ ) + "\\n" + "\\n".join(
+ f"""execute if score #__MODE___seconds {ns}.data matches 0 run title @a[scores={{{ns}.lang={lang}}},tag=!detached] title {{"text":"{go}","color":"red"}}"""
+ for lang, go in ((0, "GO !"), (1, "GO!"))
+ ))
+
+ # /start
+ write_server_announce(f"{path}/start", "Lancement de la partie de __MODE__ !", "__MODE__ game is starting!")
+
+'''
+""" Body of a new mode's translations.py, with __MODE__ standing for its id. """
+
+SPEC_TEMPLATE: str = ''' ModeSpec(
+ id="__MODE__", name_fr="__NAME__",
+ min_players=2, max_players=UNLIMITED,
+ estimated_time="2-5 mins", inspiration="?", suggested_by="?", developed_by="?",
+ description=Localized(
+ fr=("Première ligne de description.", "Deuxième ligne."),
+ en=("First description line.", "Second line."),
+ ),
+ ),'''
+""" MODES entry to paste in definitions.py, with __MODE__ and __NAME__ standing for the mode. """
+
+
+# Classes
+class NewMode:
+
+ @staticmethod
+ def display_name(mode: str) -> str:
+ """ Default human readable name built from a mode id.
+
+ Args:
+ mode (str): The mode id, ex: "tnt_run"
+ Returns:
+ str: The name, ex: "Tnt Run"
+
+ Examples:
+ >>> NewMode.display_name("tnt_run")
+ 'Tnt Run'
+ """
+ return mode.replace("_", " ").title()
+
+ @staticmethod
+ def create(mode: str) -> Path:
+ """ Write the package of a new mode.
+
+ Args:
+ mode (str): The mode id, ex: "tnt_run"
+ Returns:
+ Path: The created folder
+ """
+ folder: Path = MODES_FOLDER / mode
+ folder.mkdir()
+ (folder / "__init__.py").write_text("", encoding="utf-8")
+ (folder / "main.py").write_text(MAIN_TEMPLATE.replace("__MODE__", mode), encoding="utf-8")
+ (folder / "translations.py").write_text(TRANSLATIONS_TEMPLATE.replace("__MODE__", mode), encoding="utf-8")
+ return folder
+
+ @staticmethod
+ def main() -> int:
+ """ Read the mode id from the command line, scaffold it, then print what is left to do.
+
+ Returns:
+ int: Process exit code
+ """
+ if len(sys.argv) != 2:
+ stp.error("usage: python tools/new_mode.py ")
+ return 1
+
+ mode: str = sys.argv[1]
+ if not VALID_ID.match(mode):
+ stp.error(f"'{mode}' is not a valid mode id: lowercase letters, digits and underscores only")
+ return 1
+ if (MODES_FOLDER / mode).exists():
+ stp.error(f"src/datapack/modes/{mode}/ already exists")
+ return 1
+
+ stp.info(f"Created {NewMode.create(mode)}")
+ stp.info("It already builds. Launch it in game with:")
+ stp.info(f" /function switch:test_mode\n /function switch:modes/{mode}/_force_start")
+ stp.info("To put it in the vote, paste this into MODES in src/datapack/modes/definitions.py:\n")
+ print(SPEC_TEMPLATE.replace("__MODE__", mode).replace("__NAME__", NewMode.display_name(mode)))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(NewMode.main())
+
diff --git a/tools/report_merged_functions.py b/tools/report_merged_functions.py
new file mode 100644
index 000000000..5386b5e26
--- /dev/null
+++ b/tools/report_merged_functions.py
@@ -0,0 +1,132 @@
+""" Report every datapack function written by more than one emitter during the build.
+
+write_function() appends by default, so two emitters targeting the same path merge silently in
+call order. That is a legitimate builder pattern in a few places and a landmine everywhere else.
+This runs the pipeline in a throwaway output folder and fails on any merge not declared below.
+"""
+# Imports
+import shutil
+import sys
+import tempfile
+from collections import Counter
+from fnmatch import fnmatch
+from pathlib import Path
+from typing import Any, ClassVar
+
+import stewbeet
+import stouputils as stp
+from beet import Function, run_beet
+from stewbeet.core.utils.io import functions as functions_module # pyright: ignore[reportMissingTypeStubs]
+from stewbeet.utils import get_project_config
+
+# Constants
+DECLARED_MERGES: tuple[str, ...] = (
+ "switch:shop/*",
+ "switch:player/username_change/update_shops",
+ "switch:engine/pop_ups/schedule",
+ "switch:modes/*/give_items",
+ "switch:modes/sheepwars/team_and_give",
+ "switch:modes/build_battle/preparation/themes_list",
+ "switch:load",
+ "switch:v*/*",
+)
+""" Function paths intentionally built by several successive appends, as fnmatch patterns.
+
+The shop family accumulates one block per upgrade, pop_ups/schedule one revoke per mode and
+language, and the give_items family is a hand-written body completed by the Kit renderer.
+The switch:load and switch:v*/* entries belong to StewBeet's versioned loading scheme.
+"""
+
+SKIPPED_PLUGINS: tuple[str, ...] = ("archive", "copy_to_destination", "compute_sha1", "merge_smithed_weld")
+""" Pipeline steps dropped for the report run: they only package and ship the output. """
+
+
+# Classes
+class MergedFunctions:
+
+ WRITES: ClassVar[Counter[str]] = Counter()
+ """ How many times each function path was written during the build. """
+
+ original = staticmethod(functions_module.write_function)
+ """ The unpatched StewBeet write_function, kept so the patch can delegate to it. """
+
+ @staticmethod
+ def patched_write_function(path: str, content: str, *args: Any, **kwargs: Any) -> Function | None:
+ """ Count the write, then delegate to the real implementation.
+
+ Args:
+ path (str): The function path, ex: "switch:modes/tnt_run/tick"
+ content (str): The function body
+ Returns:
+ Function | None: Whatever StewBeet returned
+ """
+ MergedFunctions.WRITES[path.removesuffix(".mcfunction")] += 1
+ return MergedFunctions.original(path, content, *args, **kwargs)
+
+ @staticmethod
+ def build_into(output: Path) -> None:
+ """ Run the project pipeline, writing to a throwaway output folder.
+
+ Args:
+ output (Path): Folder receiving the generated packs, never the tracked build/
+ """
+ config = get_project_config()
+ config.output = str(output)
+ config.pipeline = [step for step in config.pipeline
+ if not any(skipped in str(step) for skipped in SKIPPED_PLUGINS)]
+
+ # The project cache holds the decoded assets, without it the run re-downloads everything
+ with run_beet(config, cache=True):
+ pass
+
+ @staticmethod
+ def is_declared(path: str) -> bool:
+ """ Whether a merged path is a known, intentional builder.
+
+ Args:
+ path (str): The function path, ex: "switch:shop/sheepwars"
+ Returns:
+ bool: True when the path matches one of DECLARED_MERGES
+
+ Examples:
+ >>> MergedFunctions.is_declared("switch:shop/sheepwars")
+ True
+ >>> MergedFunctions.is_declared("switch:modes/tnt_run/tick")
+ False
+ """
+ return any(fnmatch(path, pattern) for pattern in DECLARED_MERGES)
+
+ @staticmethod
+ def main() -> int:
+ """ Build, then report every function path written more than once.
+
+ Returns:
+ int: Process exit code, 0 when every merge is declared
+ """
+ functions_module.write_function = MergedFunctions.patched_write_function
+ stewbeet.write_function = MergedFunctions.patched_write_function
+
+ output: Path = Path(tempfile.mkdtemp(prefix="switch_merge_report_"))
+ try:
+ MergedFunctions.build_into(output)
+ finally:
+ shutil.rmtree(output, ignore_errors=True)
+
+ merged: dict[str, int] = {path: count for path, count in MergedFunctions.WRITES.items() if count > 1}
+ undeclared: dict[str, int] = {path: count for path, count in merged.items() if not MergedFunctions.is_declared(path)}
+
+ stp.info(f"{sum(MergedFunctions.WRITES.values())} writes over {len(MergedFunctions.WRITES)} distinct paths")
+ stp.info(f"{len(merged)} merged path(s), {len(merged) - len(undeclared)} declared")
+
+ if not undeclared:
+ return 0
+
+ stp.error(f"{len(undeclared)} undeclared merge(s): add merge=True at the call site, or declare the path")
+ for path, count in sorted(undeclared.items(), key=lambda item: -item[1]):
+ stp.error(f" {count}x {path}")
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(MergedFunctions.main())
+