Compare commits

..
Author SHA1 Message Date
koka c8531aac4b docs: organiser la production TikTok 2026-09-01 03:50:29 +02:00
koka 3ffa9b889f docs: ajouter les 100 premiers scripts TikTok 2026-09-01 02:35:12 +02:00
101 changed files with 1727 additions and 5697 deletions
-56
View File
@@ -6,62 +6,6 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve
- Depuis `26.2.0-alpha.6`, le pack et les modules ont des versions indépendantes. I Like To Move It et Only Fun deviennent les cinquième et sixième modules actifs dans l'alpha 46.
- Les versions ci-dessous ont été reconstituées à partir des JAR archivés, des packs locaux et du code source actuel. Les dates n'étant pas enregistrées de manière fiable, elles ne sont pas inventées.
## Non publié
Modules modifiés : **Sanctuary `0.0.0-alpha.137`** et **Red-Stoner `0.0.0-alpha.18`**.
- transforme le panneau de trophées en plaque verticale de chêne, corrige le texte auparavant masqué et utilise les quatre lignes pour le titre paginé ainsi que les trois places du podium ;
- permet de passer à la page suivante d’un clic droit et de revenir à la précédente avec `Maj` + clic droit, sans changer les IDs, les sauvegardes Red-Stoner v4 ni le protocole réseau 7.
- garantit sur chaque niveau de la Backroom un coffre par cellule de 3 × 3 chunks dans les nouveaux chunks, au lieu d’un coffre aléatoire réparti entre les cinq niveaux ;
- resserre aussi la grille des lits de sortie de 10 × 10 à 3 × 3 chunks sur chacun des cinq niveaux afin qu’ils soient réellement trouvables ;
- ne change ni le butin ordinaire des coffres, ni les données Sanctuary v15, ni le protocole réseau 24 ; les nouveaux coffres et lits apparaissent dans les chunks qui n’ont pas encore été générés.
## `26.2.0-alpha.230`
Modules modifiés : **Sanctuary `0.0.0-alpha.136`** et **Red-Stoner `0.0.0-alpha.17`**.
- attribue les demandes de quêtes personnalisées terminées à leur preneur directement sur le panneau et dans les détails au clic, avec son pseudonyme et sa couleur conservés ;
- répartit les lits de sortie rares sur chacun des cinq niveaux de la Backroom, avec exactement un lit par cellule de 10 × 10 chunks et par niveau dans les nouveaux chunks ;
- conserve les IDs, les données Sanctuary v14 et le protocole réseau 24 ; les chunks Backroom déjà générés restent inchangés.
- ajoute l’API publique Sanctuary de métriques et de classements persistants par UUID, avec import monotone des statistiques vanilla, podium borné et exclusion des actions créatives, spectateur ou simulées ;
- ajoute `redstoner:trophy_panel`, panneau mural configurable par son propriétaire avec un item non consommé, pages publiques, rendu en monde et rafraîchissement serveur limité à une fois par seconde ;
- passe Sanctuary aux données v15 et Red-Stoner aux données v4 sans nouveau payload ni changement de leurs protocoles réseau 24 et 7.
- corrige la cape blanche décorée en projetant sa couleur et ses motifs avec les sprites de bouclier, aux proportions exactes de la cape ;
- permet d’équiper une poule adulte avec l’armure de loup vanilla en écailles de tatou, de la réparer avec des écailles et de la retirer aux cisailles ; l’armure absorbe les dégâts et conserve son usure, ses teintures et ses enchantements ;
- conserve les sauvegardes Sanctuary v15 et le protocole réseau 24 : l’armure utilise exclusivement le slot d’équipement vanilla de la poule.
## `26.2.0-alpha.229`
Module modifié : **Red-Stoner `0.0.0-alpha.16`**.
- ajoute au terminal les tris alphabétique, par quantité croissante et par quantité décroissante, appliqués explicitement sans déplacer les objets sous le curseur lors des rafraîchissements périodiques ;
## `26.2.0-alpha.228`
Modules modifiés : **Sanctuary `0.0.0-alpha.132`** et **Red-Stoner `0.0.0-alpha.15`**.
- étend `sanctuary:backroom` à toute la hauteur Y -64 → 383 et empile cinq niveaux thématiques de haut en bas : seuil modulaire, archives, ville, services et glitch ;
- remplace les volumes ouverts par des pièces et couloirs raccordés, quatre angles de salle déclinés en cinq matériaux, des bibliothèques immenses, des tours plus serrées, des autoroutes continues et de rares parcs ;
- ajoute au niveau inférieur une grille de prismes 3D et un labyrinthe déterministe connecté, avec portes physiquement ouvertes, salles fusionnées et trous volontaires vers le vide ;
- fait correspondre exactement chaque ouverture entre le sol supérieur et le plafond inférieur, place une torche récupérable à chaque arrivée et raréfie fortement les lits de sortie ;
- supprime le ciel, les astres, la lumière ambiante et tous les monstres, y compris ceux injectés par un autre système ;
- conserve les IDs, les données Sanctuary v14 et le protocole réseau 24 ; seuls les nouveaux chunks Backroom utilisent la nouvelle architecture complète ;
- rend le champ de recherche du terminal inactif à l’ouverture puis focalisable au clic, libère le focus au clic extérieur et laisse toujours la touche d’inventaire fermer le terminal ;
- permet à la recherche du terminal de retrouver une boîte de Shulker par le nom ou l’identifiant d’un objet qu’elle contient, tout en conservant la boîte comme résultat afin que son aperçu vanilla reste directement consultable ;
## `26.2.0-alpha.227`
Modules modifiés : **Sanctuary `0.0.0-alpha.131`** et **Another World `0.0.0-alpha.57`**.
- ajoute la dimension persistante `sanctuary:backroom`, accessible en s’accroupissant devant un lit et quittée uniquement par un lit, avec arrivées variées dans une zone architecturale de 2 000 blocs ;
- transfère atomiquement vers la Mailbox tout l’équipement d’entrée, y compris les rangées étendues, l’œuf compagnon et la cape, avec reprise idempotente après interruption ;
- conserve les objets perdus au dépop, dans le vide et lors des morts en Backroom dans un registre borné et non duplicable ;
- transforme tous les coffres de la dimension en butin renouvelable, pauvre la plupart du temps, qui privilégie les pertes personnelles dans les premiers coffres avant les pertes anciennes partagées ;
- génère des villes, bureaux, plaines, agrégats flottants et pièces visuellement retournées à partir de palettes composées, en réservant les matériaux d’Another World aux anomalies et les blocs précieux à de rares cachettes ;
- fait enregistrer la mousse turquoise par Another World via l’API publique de matériaux Backroom, sans dépendance circulaire ;
- actualise Fabric API vers `0.159.0+26.2` et JEI vers `30.29.0.199`, avec empreintes Packwiz et Prism synchronisées ;
- migre Sanctuary vers les données v14 sans changer le protocole réseau 24, sans réinitialiser les sauvegardes ni effacer les constructions.
## `26.2.0-alpha.226`
Module modifié : **Sanctuary `0.0.0-alpha.130`**. Les neuf autres modules conservent leur version issue de l'alpha.225.
+6 -18
View File
@@ -1,6 +1,6 @@
# Sanctuary 26.2
Workspace Fabric multi-projet de Sanctuary 2.0. Il construit dix JAR indépendants sur Minecraft 26.2, Fabric Loader 0.19.3, Fabric API 0.159.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25.
Workspace Fabric multi-projet de Sanctuary 2.0. Il construit dix JAR indépendants sur Minecraft 26.2, Fabric Loader 0.19.3, Fabric API 0.158.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25.
Auteur : **KOKA99CAB**
Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
@@ -12,27 +12,17 @@ Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
| `anotherworld` | Another World | choses matérielles | matière, photographie, instruments météo et 108 biomes natifs |
| `itsalive` | It's Alive ! | vivant | Creepers, créatures, agriculture et gameplay serveur complet de Canaplia |
| `ambiance` | Ambiance | perception | atmosphère, météo, lumières dynamiques, audio positionnel, calendrier et composition visuelle accessible |
| `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, terminal de stockage, panneau de trophées, Drawer et Particuleur |
| `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, terminal de stockage, Drawer et Particuleur |
| `iliketomoveit` | I Like To Move It | déplacement | portails, véhicules, grappin, poulies et waystones locaux |
| `ouch` | Ouch | combat et destruction | arsenal actif (6 armes) et TNT météo |
| `onlyfun` | Only Fun | règles ludiques absurdes | lootboxes, Lucky Block, raretés et règles absurdes |
| `sanctuary` | Sanctuary | progression et aventure | progression globale, classements publics, Atlas et monde principal flottant |
| `sanctuary` | Sanctuary | progression et aventure | progression globale, Atlas et monde principal flottant |
| `events` | Events | événements serveur | socle vide |
| `masterkey` | MasterKey | administration et diagnostic | laboratoire minimal |
Les descriptions FR/EN/RU sont déclarées dans chaque `fabric.mod.json` et dans les fichiers de langue. Les images fournies servent d'icônes Fabric de leur module.
Version courante du pack distribué : **`26.2.0-alpha.230`**. Cette release distribue Sanctuary **`0.0.0-alpha.136`** et Red-Stoner **`0.0.0-alpha.17`** ; les huit autres modules conservent leur version issue de l'alpha.229.
Le prochain Red-Stoner **`0.0.0-alpha.18`** présente le panneau de trophées comme une plaque verticale de chêne et rend enfin ses quatre lignes lisibles : titre et numéro de page, puis les trois places du podium. Un clic droit avance d’une page et `Maj` + clic droit revient à la précédente ; les données v4 et le protocole réseau 7 restent inchangés.
L’alpha.230 attribue les quêtes communautaires terminées à leur preneur et répartit les lits de sortie rares sur chacun des cinq niveaux de la Backroom. Elle ajoute aussi les classements publics et le panneau mural `redstoner:trophy_panel`, corrige la projection des couleurs et motifs de la cape blanche avec les sprites de bouclier, et permet d’équiper une poule adulte avec l’armure de loup vanilla en écailles de tatou. Les sauvegardes restent compatibles ; Sanctuary passe aux données v15 et Red-Stoner aux données v4 sans changer leurs protocoles réseau 24 et 7.
L’alpha.229 ajoute au terminal les tris alphabétique, par quantité croissante et par quantité décroissante. Le tri ne change que sur demande explicite et les rafraîchissements périodiques conservent l’ordre ainsi que les objets sous le curseur. Les sauvegardes, les données Red-Stoner v3 et le protocole réseau 7 restent compatibles.
L’alpha.228 reconstruit la Backroom sur cinq niveaux empilés entre Y -64 et Y 383. Le monde devient entièrement noir et sans monstres ; l’arrivée est signalée par une torche récupérable. Les nouveaux chunks composent seuils modulaires, bibliothèques et bureaux, ville dense avec autoroutes et parcs, couloirs techniques, puis une grille 3D et un labyrinthe connecté dont certains trous mènent au vide. Red-Stoner corrige aussi le focus du terminal et permet de retrouver une boîte de Shulker par son contenu. Les chunks déjà générés et les sauvegardes existantes restent compatibles.
L’alpha.227 ajoute la dimension persistante `sanctuary:backroom`. On y entre accroupi devant un lit et on n’en ressort que par un lit. L’équipement d’entrée est transféré atomiquement vers la Mailbox, tandis que les objets perdus ou abandonnés dans la dimension alimentent des coffres renouvelables sans duplication. Les anciennes sauvegardes sont migrées vers Sanctuary data v14 sans réinitialisation ; le protocole réseau reste en version 24.
Version courante du pack distribué : **`26.2.0-alpha.226`**. Cette release distribue Sanctuary **`0.0.0-alpha.130`** ; les neuf autres modules conservent leur version issue de l'alpha.225.
L’alpha.225 corrige quatre tickets : posséder un coffre-fort devient obligatoire pour en braquer un autre, les familiers temporaires ne donnent plus de butin, d’expérience, de lootbox ni de progression de quête, les seize variantes Canaplia survivent à tout leur cycle de culture, et le Magic Carpet retrouve la texture historique du Totem de Notch à la place de la laine violette. Les IDs, sauvegardes, formats de données et protocoles restent compatibles.
@@ -50,7 +40,7 @@ L’alpha.220 transforme le terminal de stockage en véritable menu de conteneur
L’alpha.219 porte l'île finie Minecraft Indev dans `sanctuary:alpha` : 128 × 128 × 64 blocs déterministes, plages, cavernes, minerais, fluides, végétation et maison historique, le tout borné sans chunks parasites. Le cadre 4 × 5 en cobblestone moussue est maintenant actif et crée une paire persistante à une arrivée sûre hors de la maison. Ambiance peut enfin composer localement Golden Days Base puis Alpha uniquement dans cette dimension, sans redistribuer leurs textures et en restaurant exactement la pile précédente. Voir le [contrat Alpha](sanctuary/ALPHA.md), le [contrat des portails](iliketomoveit/PORTALS.md) et le [guide Golden Days](ambiance/GOLDEN_DAYS.md).
L’alpha.218 ajoute le terminal de stockage Red-Stoner : cette demi-dalle verticale se fixe sur un coffre, un coffre piégé ou un baril et donne accès, par pages défilantes de 54 types, à un réseau contigu borné à 128 conteneurs chargés. Les retraits et dépôts sont validés par le serveur, les composants d’items restent strictement séparés et le terminal endgame est exclu du Shop, du Black Market et des récompenses Only Fun. La recherche retrouve aussi une boîte de Shulker d’après son contenu direct ; la boîte reste affichée dans la grille afin que son aperçu vanilla soit consultable sans modifier les règles de retrait. Un bouton permet enfin de trier explicitement les résultats par nom, quantité croissante ou quantité décroissante.
L’alpha.218 ajoute le terminal de stockage Red-Stoner : cette demi-dalle verticale se fixe sur un coffre, un coffre piégé ou un baril et donne accès, par pages défilantes de 54 types, à un réseau contigu borné à 128 conteneurs chargés. Les retraits et dépôts sont validés par le serveur, les composants d’items restent strictement séparés et le terminal endgame est exclu du Shop, du Black Market et des récompenses Only Fun.
L’alpha.217 tentait de compenser la hauteur trop grave du disque blanc par un facteur OpenAL de `4/3`. Cette compensation est retirée par l’alpha.221, qui corrige directement la conversion stéréo vers mono.
@@ -323,9 +313,7 @@ Le guide détaillé est dans [`masterkey/tools/PANORAMA.md`](masterkey/tools/PAN
## Red-Stoner et photographie
Red-Stoner possède sept blocs actifs sous son namespace : `eight_bit_computer`, `controller`, `personal_computer`, `drawer`, `particuleur`, `storage_terminal` et `trophy_panel`. Son onglet créatif expose aussi les trois appareils Canon et les seize styles de pellicule.
Le panneau mural `redstoner:trophy_panel` consomme l’API publique de classement de Sanctuary sans conserver les scores. Le joueur qui le pose peut ajouter des pages statistiques en cliquant avec un item, tandis que tous les visiteurs peuvent faire défiler les pages. Sanctuary importe une fois les statistiques vanilla existantes, garde les classements par UUID — champion déconnecté compris — et fournit seulement un podium borné à trois entrées. Les composants privés de l’item servant d’icône et la base complète des joueurs ne sont jamais synchronisés au client.
Red-Stoner possède quatre blocs actifs sous son namespace : `eight_bit_computer`, `controller`, `personal_computer` et `drawer`. Son onglet créatif expose aussi les trois appareils Canon et les seize styles de pellicule.
L'ordinateur 8 bits et le contrôleur exécutent la machine virtuelle historique : programme assembleur borné à 4 096 caractères, RAM, inventaire, six ports redstone directionnels, impulsions et sortie comparateur. Le contrôleur dispose de 1 024 cellules de RAM contre 256 pour l'ordinateur. L'instruction destinée à la matrice d'affichage renvoie volontairement une erreur claire tant que ce périphérique reste différé.
+2 -32
View File
@@ -1956,7 +1956,7 @@ tasks.register("verifyAnotherWorldCatalog") {
"src/main/resources/assets/redstoner/textures/item/weather_gauge.png",
"src/main/resources/data/redstoner/recipe/weather_gauge.json"
]
if (project.version.toString() != "0.0.0-alpha.57"
if (project.version.toString() != "0.0.0-alpha.56"
|| sulfurMigration.target?.modules?.anotherworld != "0.0.0-alpha.44"
|| sulfurMigration.sulfur_caves?.sanctuary?.dripstone_cell_period != 2
|| !javaText.contains('Identifier.parse("sanctuary:sulfur_caves")')
@@ -3273,7 +3273,7 @@ tasks.register("verifyClimateBiomeMixer") {
"pack/migrations/26.2.0-alpha.188-beta-climate-provinces-to-alpha.189.json"))
def mixer = file(
"src/main/java/fr/koka99cab/sanctuary26/anotherworld/mixin/MultiNoiseBiomeSourceParameterListPresetMixin.java").text
if (project.version.toString() != "0.0.0-alpha.57"
if (project.version.toString() != "0.0.0-alpha.56"
|| migration.source?.pack_version != "26.2.0-alpha.188"
|| migration.target?.pack_version != "26.2.0-alpha.189"
|| migration.target?.modules?.anotherworld != "0.0.0-alpha.48"
@@ -3300,33 +3300,3 @@ tasks.register("verifyClimateBiomeMixer") {
tasks.named("check") {
dependsOn tasks.named("verifyClimateBiomeMixer")
}
tasks.register("verifyBackroomMaterialIntegration") {
group = "verification"
description = "Checks the one-way Another World material integration with Sanctuary Backroom."
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/AnotherWorldMod.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldBlocks.java"))
inputs.file(rootProject.file(
"sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java"))
doLast {
def initializer = file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/AnotherWorldMod.java").text
def blocks = file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldBlocks.java").text
def api = rootProject.file(
"sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java").text
def rootBuild = rootProject.file("build.gradle").text
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (project.version.toString() != "0.0.0-alpha.57"
|| !initializer.contains("BackroomMaterialApi.registerAnomalyMaterial(AnotherWorldBlocks.TURQUOISE_MOSS)")
|| !blocks.contains('"turquoise_moss"')
|| !api.contains("public static void registerAnomalyMaterial")
|| manifest.depends?.sanctuary != ">=0.0.0-alpha.131"
|| !rootBuild.contains('project(":anotherworld")')
|| !rootBuild.contains('implementation project(":sanctuary")')) {
throw new GradleException("Another World alpha.57 Backroom material integration is incomplete")
}
}
}
tasks.named("check") {
dependsOn tasks.named("verifyBackroomMaterialIntegration")
}
@@ -4,9 +4,7 @@ import fr.koka99cab.canon.CanonMod;
import fr.koka99cab.sanctuary26.anotherworld.item.WeatherCompassItem;
import fr.koka99cab.sanctuary26.anotherworld.network.AnotherWorldNetworking;
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldRegistries;
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldBlocks;
import fr.koka99cab.sanctuary26.anotherworld.vault.VaultService;
import fr.koka99cab.sanctuary26.sanctuary.api.backroom.BackroomMaterialApi;
import net.fabricmc.api.ModInitializer;
import net.minecraft.resources.Identifier;
import org.slf4j.Logger;
@@ -28,7 +26,6 @@ public final class AnotherWorldMod implements ModInitializer {
@Override
public void onInitialize() {
AnotherWorldRegistries.initialize();
BackroomMaterialApi.registerAnomalyMaterial(AnotherWorldBlocks.TURQUOISE_MOSS);
VaultService.initialize();
WeatherCompassItem.registerFrameInteraction();
CanonMod.initialize();
@@ -31,7 +31,7 @@
"java": ">=25",
"fabric-api": ">=0.154.2+26.2",
"ambiance": "*",
"sanctuary": ">=0.0.0-alpha.131"
"sanctuary": ">=0.0.0-alpha.117"
},
"custom": {
"sanctuary26": {
+272 -831
View File
File diff suppressed because it is too large Load Diff
+404
View File
@@ -0,0 +1,404 @@
# Sanctuary TikTok — organisation de réalisation
Ce document transforme les [100 premiers scripts](VIDEOS_001_100.md) en chaîne de production solo, reproductible et extensible. L'objectif est de produire vite sans perdre la sobriété, la précision technique ni la cohérence de Sanctuary.
## Objectif de la saison 001
- Produire les 100 vidéos en **10 à 12 semaines**.
- Publier **5 vidéos par semaine**, soit 20 semaines de diffusion.
- Maintenir en permanence **au moins 15 vidéos terminées d'avance**.
- Valider le langage visuel sur 12 pilotes avant d'industrialiser les 88 suivantes.
- Utiliser les performances pour choisir les prochains sujets, sans réécrire l'identité de la série à chaque publication.
Charge solo réaliste : **14 à 18 heures par semaine**, soit environ **140 à 180 heures** pour la saison complète. Les vidéos d'interface peuvent demander moins d'une heure ; les scènes multijoueur, worldgen ou avant/après authentiques peuvent en demander deux à quatre.
## Principe directeur
La publication suit les numéros. La réalisation suit les décors.
Tourner dix vidéos partageant la même ferme, le même laboratoire ou la même lumière est plus efficace que de réaliser les épisodes 001 à 010 dans l'ordre. Le numéro reste l'identité publique de la vidéo ; le lot reste son identité de production.
## Les neuf plateaux de tournage
| Code | Plateau | Épisodes principaux | Préparation indispensable |
|---|---|---|---|
| `UI` | Laboratoire interface | 003–019, 083–086, 097 | profils de progression, inventaires remplis, terminal et Shop |
| `WRLD` | Archipel et worldgen | 002, 020–027, 053, 072–079 | seed verrouillée, points de vue, météo et heures reproductibles |
| `ALPH` | Alpha / maison Indev | 028–031 | portail, maison intacte, état de Notch et récompense réinitialisable |
| `MOVE` | Terrain de déplacement | 032–039 | tours, deux niveaux de Waystones, ligne de poulies et trajectoire de grappin |
| `PASS` | Bestiaire calme | 040–049, 053 | enclos invisibles ou hors champ, variantes préparées, fleurs et reproduction |
| `HOST` | Bestiaire hostile | 050–059 | arène sombre, protections opérateur, cibles mobiles et second joueur si nécessaire |
| `FARM` | Ferme et cuisine | 060–071 | parcelles par stade, six postes, pupitre, stocks d'ingrédients et deux joueurs |
| `TECH` | Atelier technologique | 080–090 | Canon, terminal, ordinateurs, réseau de coffres et deux coffres-forts appartenant à des joueurs distincts |
| `OUCH` | Stand de tir / place serveur | 091–100 | cibles, munitions, protections, Shop, panneaux, Mailboxes et second joueur |
Les commandes MasterKey servent à préparer les acteurs et les états, jamais à apparaître dans l'image finale. Les scènes doivent ensuite montrer le comportement de jeu normal.
## Les six vagues de production
### P — Pilote, 12 vidéos
`001, 003, 006, 014, 020, 031, 041, 053, 062, 077, 086, 099`
But : éprouver le manifeste, l'interface, le monde, une mécanique spectaculaire, une créature, l'agriculture, l'ambiance et le multijoueur.
Livrable : 12 exports finalisés, mais seulement les quatre premiers sont programmés immédiatement. Les huit autres restent disponibles pour corriger le système avant une diffusion complète.
### A — Progression et interfaces, 20 vidéos
`002, 004, 005, 007–013, 015–019, 082–085, 097`
But : rentabiliser le laboratoire UI et verrouiller la manière de filmer les menus verticaux.
### B — Monde et Ambiance, 19 vidéos
`021–030, 072–076, 078–081`
But : tourner toutes les variations de jour, nuit, météo, shader et dimension pendant que la seed et les cadrages sont verrouillés.
### C — Déplacement et bestiaire, 20 vidéos
`032–040, 042–052`
But : mutualiser les acteurs, les enclos, les caméras latérales et les scènes nécessitant du mouvement.
### D — Infectés, agriculture et Canaplia, 17 vidéos
`054–061, 063–071`
But : partager l'arène hostile, la ferme à stades contrôlés, les ingrédients et le deuxième joueur.
### E — Technologie, combat et systèmes sociaux, 12 vidéos
`087–096, 098, 100`
But : terminer avec les scènes demandant le plus de préparation matérielle et de règles serveur.
Ces six listes couvrent les 100 épisodes une seule fois. Une vidéo pilote ne doit pas être retournée pendant sa vague thématique sauf si le test révèle un défaut concret.
## Calendrier conseillé
### Semaine 0 — Installer l'usine
- Verrouiller la seed, la version du pack et les réglages vidéo.
- Créer les neuf plateaux ou sauvegardes de référence.
- Choisir la typographie, la taille du texte et l'unique animation de carton final.
- Créer un projet de montage modèle avec trois pistes vidéo, voix, sons du jeu, ambiance et sous-titres.
- Exporter un test de dix secondes et le regarder sur un téléphone réel.
- Préparer les douze fiches du pilote et leurs commandes de laboratoire.
### Semaine 1 — Produire le pilote
- Jour 1 : préparer les scènes `003, 006, 014, 086`.
- Jour 2 : capturer `001, 020, 053, 077` dans le monde.
- Jour 3 : capturer `031, 041, 062, 099` et les scènes multijoueur.
- Jour 4 : monter les douze versions brutes.
- Jour 5 : voix, texte, son, exports et contrôle qualité.
- Jour 6 : corrections et programmation des quatre premières publications.
### Semaine 2 — Publier et corriger le système
- Publier quatre vidéos espacées.
- Relever les performances à J+1 sans changer les vidéos déjà programmées au premier commentaire négatif.
- Identifier les problèmes répétés de lisibilité, de rythme ou de compréhension.
- Corriger uniquement le modèle de production concerné : accroche, taille de texte, cadrage, son ou durée.
- Commencer la vague A pendant la diffusion.
### Semaines 3 à 11 — Régime de croisière
- Produire environ dix nouvelles vidéos par semaine.
- Publier cinq vidéos par semaine.
- Consacrer une semaine complète aux grandes vagues A, B et C lorsqu'elles exigent davantage de scènes.
- Garder la semaine 11 pour les reprises, les scènes multijoueur manquantes et les exports finaux.
### Semaines 12 à 20 — Diffuser avec une saison d'avance
- Continuer la publication des 100 vidéos.
- Concevoir la saison 101–200 à partir des formats qui fonctionnent, pas seulement des sujets qui ont fait le plus de vues.
- Maintenir un tampon minimum de quinze exports prêts.
## Semaine type en régime de croisière
| Jour | Travail | Durée cible | Livrable |
|---|---|---:|---|
| Lundi | verrouillage de dix scripts, vérification des sources, liste des accessoires | 2 h | dix fiches `LOCKED` |
| Mardi | préparation des scènes, acteurs, inventaires, heure et météo | 3 h | dix scènes `READY` |
| Mercredi | capture groupée du lot | 4 h | rushes nommés et vérifiés |
| Jeudi | montage image des dix vidéos | 4 h | dix montages `V1` |
| Vendredi | voix, texte, mixage, export et QC | 3 à 4 h | dix masters `APPROVED` |
| Samedi | programmation et revue des publications précédentes | 1 h | calendrier et décisions J+7 |
Ne pas imposer cette répartition lorsque la scène dicte autre chose. Une nuit de sang, un combat multijoueur ou une météo rare doivent être capturés en série pendant que leur état est disponible.
## Pipeline d'une vidéo
Chaque vidéo passe par un seul état à la fois :
```text
BACKLOG
→ LOCKED
→ READY
→ CAPTURED
→ V1
→ TEXT_AUDIO
→ QC
→ APPROVED
→ SCHEDULED
→ PUBLISHED
→ REVIEWED
```
- `BACKLOG` : sujet présent dans le catalogue.
- `LOCKED` : accroche, plans, texte et source validés pour la version distribuée du pack.
- `READY` : décor, acteurs, objets et état du monde préparés.
- `CAPTURED` : tous les rushes existent, sont lisibles et sauvegardés.
- `V1` : montage image complet sans finition.
- `TEXT_AUDIO` : texte exact, voix et sons finalisés.
- `QC` : contrôle sur ordinateur puis téléphone.
- `APPROVED` : master immuable prêt à publier.
- `SCHEDULED` : date, légende et couverture enregistrées.
- `PUBLISHED` : URL et date réelles ajoutées au suivi.
- `REVIEWED` : performances J+1 et J+7 enregistrées, décision de suite prise.
Une vidéo qui échoue au contrôle revient à l'état concerné. Elle ne saute jamais directement de `CAPTURED` à `SCHEDULED`.
## Arborescence locale des médias
Les rushes et exports sont trop lourds pour Git. Les conserver hors du dépôt, par exemple sous `/Users/koka/Movies/Sanctuary-TikTok/Season-001/` :
```text
Season-001/
├── 00-shared/
│ ├── brand/
│ ├── music-and-sfx/
│ ├── project-template/
│ └── export-presets/
├── 01-pilot/
├── 02-wave-a-ui/
├── 03-wave-b-world/
├── 04-wave-c-movement-living/
├── 05-wave-d-food-canaplia/
├── 06-wave-e-tech-combat-social/
└── episodes/
└── SNC-TT-001/
├── brief/
├── capture/
├── audio/
├── project/
├── exports/
└── cover/
```
Le dépôt Git conserve uniquement les scripts, le plan, les éventuels manifests texte et les décisions éditoriales. Il ne doit recevoir ni rushes, ni projets de montage lourds, ni exports vidéo.
## Convention de fichiers
Identifiant canonique : `SNC-TT-<numéro sur trois chiffres>`.
Exemples :
```text
SNC-TT-041__capture-01__wide.mov
SNC-TT-041__capture-02__flower.mov
SNC-TT-041__voice-fr__v02.wav
SNC-TT-041__master-fr__v03.mp4
SNC-TT-041__cover-fr__v01.png
SNC-TT-041__metadata.yml
```
Règles :
- ne jamais employer `final-final`, `nouveau` ou `ok2` ;
- incrémenter `v01`, `v02`, `v03` ;
- ne jamais remplacer un master `APPROVED` ; créer une nouvelle version ;
- conserver la langue dans le nom du master pour préparer EN et RU ;
- inscrire la version exacte du pack capturé dans `metadata.yml` ;
- conserver les musiques, sons et images avec leur provenance et leurs droits.
## Fiche de préparation obligatoire
Avant la capture, chaque épisode reçoit ces champs :
```yaml
id: SNC-TT-041
status: LOCKED
pack_version: 26.2.0-alpha.226
capture_batch: PASS
format: interaction
target_duration_seconds: 11
source_verified: true
actors: 1
multiplayer_required: false
world_snapshot: passive-mobs-v1
shots:
- dangerous-approach
- flower-given
- happy-follow
rights_checked: true
```
Le numéro de version sert à empêcher la publication tardive d'une règle devenue fausse après une mise à jour. Avant programmation, comparer le comportement capturé à la version actuellement distribuée.
## Préparation des scènes
### Décors
- Utiliser des volumes simples, de grandes surfaces lisibles et un fond sans détails concurrents.
- Réserver la pièce blanche ou gris très clair aux vidéos de laboratoire.
- Réserver le décor naturel aux découvertes et chroniques.
- Conserver les coordonnées et l'orientation de chaque caméra dans la fiche de scène.
- Pour un avant/après, réutiliser exactement la même caméra, la même heure et la même action.
### Caméras
N'utiliser que quatre familles de plans :
1. **preuve FPS** : interaction telle que le joueur la vit ;
2. **caméra latérale fixe** : mouvement, créature ou mécanisme ;
3. **plan large lent** : monde, météo ou révélation ;
4. **gros plan d'interface** : valeur, état ou changement de rangée.
Un cinquième type de plan doit justifier son existence. Aucun mouvement de caméra ne doit concurrencer la mécanique montrée.
### Portrait
- Capturer directement en portrait lorsque l'interface est la feature.
- Si la source est horizontale, cadrer toute l'action dans la zone verticale centrale dès le tournage.
- Tester la taille du GUI et les sous-titres sur téléphone avant de tourner tout un lot.
- Garder le texte loin des zones extrêmes supérieure et inférieure de l'écran.
### Multijoueur
Réserver une session acteur de deux à trois heures toutes les deux semaines. Y regrouper au minimum : `031, 037, 039, 048, 057, 065, 071, 090, 098 et 099`.
Préparer avant l'arrivée du second joueur : inventaires, permissions, Mailboxes, objectifs, position de caméra et état de sauvegarde. Le temps acteur doit servir à jouer la scène, pas à construire son décor.
## Montage minimaliste
### Structure par défaut
```text
0,0–1,5 s preuve ou anomalie visible
1,5–5,0 s action principale
5,0–10,0 s conséquence
10,0–14,0 s règle ou précision
dernier 0,5–1,0 s carton Sanctuary
```
Ce rythme est un point de départ. Une chronique contemplative peut respirer davantage ; une interaction évidente peut s'arrêter après huit secondes.
### Texte
- Une phrase d'accroche.
- Une phrase de compréhension ou de conséquence.
- Deux lignes maximum à la fois.
- Aucun paragraphe explicatif.
- Les nombres et noms techniques doivent être composés dans le logiciel de montage, jamais générés dans une image.
- Les sous-titres reprennent la voix sans ajouter une deuxième narration concurrente.
### Son
- Conserver le son de l'action : clic, déplacement, impact, ouverture, souffle ou ambiance.
- Utiliser le silence comme contraste avant une révélation.
- Éviter une musique différente à chaque vidéo ; une petite famille sonore suffit à créer la série.
- Vérifier les droits de tout son extérieur au jeu.
- Pour le disque blanc, n'utiliser qu'un extrait dont la publication est autorisée.
## Contrôle qualité avant publication
Une vidéo est `APPROVED` seulement si toutes les réponses sont oui :
- La feature est-elle compréhensible sans lire la légende TikTok ?
- La première preuve apparaît-elle dans les deux premières secondes ?
- Une seule idée principale est-elle défendue ?
- Les valeurs et comportements correspondent-ils encore à la version distribuée ?
- Le HUD est-il présent uniquement lorsqu'il sert de preuve ?
- Les textes restent-ils lisibles sur téléphone et limités à deux lignes ?
- L'action reste-t-elle visible sous les textes et les surfaces de l'application ?
- Le montage contient-il trois plans ou moins, sauf justification écrite ?
- Les sons, musiques et images peuvent-ils être publiés légalement ?
- Les commandes de laboratoire, messages opérateur et éléments privés sont-ils absents ?
- Le fichier porte-t-il le bon ID, la langue et la bonne version ?
- Le master a-t-il été regardé intégralement après export ?
## Publication
Cadence initiale : cinq vidéos par semaine.
| Jour | Type conseillé |
|---|---|
| Lundi | règle simple ou interface |
| Mardi | créature ou interaction |
| Mercredi | problème / correction |
| Vendredi | feature spectaculaire |
| Dimanche | chronique calme, monde ou détail absurde |
Ne pas publier dix épisodes du même module à la suite même s'ils ont été capturés ensemble. Alterner les plateaux donne l'impression d'un monde cohérent plutôt que de neuf mini-campagnes séparées.
La légende doit compléter la vidéo, pas répéter son texte. Structure recommandée :
```text
Une phrase de contexte.
La règle exacte en une phrase.
Sanctuary — épisode 041.
```
Limiter les appels à l'action. Une question réelle peut être utilisée lorsqu'elle ouvre le sujet suivant ; éviter les demandes génériques de like ou d'abonnement sur chaque publication.
## Revue des performances
Enregistrer à J+1 puis J+7 :
- vues ;
- rétention dans les deux premières secondes ;
- durée moyenne regardée ;
- taux de complétion ;
- relectures ;
- partages et enregistrements ;
- visites de profil ;
- questions ou incompréhensions récurrentes.
Comparer chaque vidéo à la médiane de son propre format : une chronique de 25 secondes ne doit pas être jugée comme une interaction de huit secondes.
Décisions :
- accroche forte et bonne complétion : produire une suite dans les sept jours ;
- accroche faible mais bonne complétion chez les spectateurs restants : recouper les deux premières secondes pour un futur sujet proche ;
- chute au moment d'une explication : raccourcir ou déplacer la phrase en légende ;
- question répétée : créer une vidéo-réponse, sans alourdir la vidéo originale ;
- bon sujet mais preuve peu visible : retourner la scène, pas seulement changer le texte ;
- résultat faible isolé : ne pas abandonner immédiatement tout le format.
## Tableau de suivi minimal
Le suivi peut rester dans un tableur ou un outil Kanban, avec une ligne par ID et ces colonnes :
| Champ | Usage |
|---|---|
| `id` | `SNC-TT-001` à `SNC-TT-100` |
| `title` | titre canonique du script |
| `batch` | `UI`, `WRLD`, `ALPH`, `MOVE`, `PASS`, `HOST`, `FARM`, `TECH` ou `OUCH` |
| `status` | état unique du pipeline |
| `pack_version` | version réellement capturée |
| `source_verified` | oui/non et date |
| `scene_ready` | oui/non |
| `multiplayer` | oui/non |
| `capture_date` | date réelle |
| `master_path` | chemin du master approuvé |
| `rights_checked` | oui/non |
| `scheduled_at` | date de programmation |
| `published_url` | URL réelle |
| `review_j1` | métriques et note courte |
| `review_j7` | métriques et décision |
| `follow_up` | nouvel ID ou action décidée |
## Prochaine action concrète
Commencer par la semaine 0, puis ne préparer que les douze pilotes. Le système n'est considéré comme verrouillé qu'après :
1. un export regardé sur téléphone ;
2. quatre publications réelles ;
3. une revue J+1 de ces quatre vidéos ;
4. une décision explicite sur la taille du texte, le rythme, le son et le carton final.
Après cette validation, les 88 vidéos restantes peuvent entrer en production industrielle sans devoir être remontées à cause d'un défaut de format découvert trop tard.
+950
View File
@@ -0,0 +1,950 @@
# Sanctuary TikTok — vidéos 001 à 100
Première saison éditoriale de TikTok sobres et minimalistes présentant Sanctuary comme une réinterprétation personnelle de Minecraft, une règle à la fois. La chaîne de production complète est décrite dans le [plan de réalisation](PRODUCTION_PLAN.md).
## Contrat de réalisation
- Format vertical 9:16, 1080 × 1920, 30 images par seconde.
- Une seule idée par vidéo, généralement en 10 à 20 secondes.
- Trois plans maximum, coupes franches, caméra fixe ou mouvement très lent.
- HUD masqué sauf lorsque l'interface constitue la preuve.
- Sons du jeu prioritaires ; musique et voix off facultatives.
- Deux lignes de texte maximum simultanément.
- Carton final de 0,5 à 1 seconde : `SANCTUARY / <SÉRIE> <NUMÉRO>`.
- Les valeurs, limites et comportements annoncés doivent rester identiques aux sources liées.
- Pour les correctifs, montrer le symptôme puis la correction ; ne jamais fabriquer un faux « avant ».
Les textes proposés sont une base de tournage. Ils peuvent être lus en voix off ou affichés à l'écran, mais ne doivent pas être doublés intégralement sur les deux canaux.
## Vue d'ensemble
| Épisodes | Axe principal |
|---|---|
| 001 | Manifeste de la série |
| 002–019 | Progression, inventaire et Atlas |
| 020–031 | Monde flottant, biomes et Alpha |
| 032–039 | Waystones, grappin et poulies |
| 040–059 | Creepers, animaux, monstres et familiers |
| 060–071 | Agriculture, cuisine et Canaplia |
| 072–081 | Temps, météo, lumière, image et audio |
| 082–090 | Red-Stoner, stockage et coffre-fort |
| 091–096 | Arsenal Ouch |
| 097–100 | Shop, marché, quêtes et Only Fun |
## 001 — Je réinterprète Minecraft, un détail à la fois
- **Format / durée :** manifeste, 15 s.
- **Accroche :** « Je ne voulais pas seulement ajouter des mods à Minecraft. »
- **Plans :** îles flottantes au loin ; Creeper portant une fleur ; inventaire qui gagne une nouvelle rangée ; coupe noire.
- **Texte / voix :** « Je voulais réinterpréter ses règles, son monde et ses créatures. Un détail à la fois. »
- **Sortie :** `SANCTUARY / MANIFESTE 001`.
- **Source :** [présentation des dix modules](../../README.md#modules).
## 002 — Ici, tu commences réellement faible
- **Format / durée :** une règle, 12 s.
- **Accroche :** « Trois cœurs. Trois morceaux de faim. »
- **Plans :** apparition sur l'île ; gros plan propre sur le HUD ; tentative de minage volontairement lente.
- **Texte / voix :** « Dans Sanctuary, le personnage vanilla complet est quelque chose que tu reconstruis. »
- **Sortie :** `SANCTUARY / PROGRESSION 002`.
- **Source :** [départ de partie et statistiques initiales](../../sanctuary/PROGRESSION.md#2-départ-de-partie).
## 003 — Tu ne commences qu'avec ta hotbar
- **Format / durée :** une règle, 10 s.
- **Accroche :** « Au début, ceci est tout ton inventaire. »
- **Plans :** ramasser plusieurs objets ; ouvrir l'inventaire ; révéler uniquement les neuf cases disponibles.
- **Texte / voix :** « Les autres rangées ne sont pas cachées. Elles ne sont pas encore à toi. »
- **Sortie :** `SANCTUARY / INVENTAIRE 003`.
- **Source :** [inventaire extensible](../../README.md#progression-sanctuary-alpha).
## 004 — La place se gagne
- **Format / durée :** interaction, 14 s.
- **Accroche :** « Ton XP peut agrandir ton inventaire. »
- **Plans :** inventaire à une rangée ; achat du rang suivant dans Progression ; réouverture avec deux rangées.
- **Texte / voix :** « Cinq achats permettent de passer d'une seule rangée à six. Les déblocages restent acquis après la mort. »
- **Sortie :** `SANCTUARY / INVENTAIRE 004`.
- **Source :** [inventaire extensible et coûts](../../sanctuary/PROGRESSION.md#44-inventaire-extensible-alpha74).
## 005 — Tab change réellement de hotbar
- **Format / durée :** interaction cachée, 10 s.
- **Accroche :** « Ta deuxième rangée peut devenir ta hotbar. »
- **Plans :** objet distinct dans deux rangées ; pression sur `Tab` ; échange visible de la rangée active sans ouvrir de menu.
- **Texte / voix :** « `Tab` avance. `Maj + Tab` revient. Le serveur échange la vraie hotbar vanilla. »
- **Sortie :** `SANCTUARY / INVENTAIRE 005`.
- **Source :** [rangées de hotbar](../../README.md#progression-sanctuary-alpha).
## 006 — Miner 256 blocs sans tricher
- **Format / durée :** démonstration, 15 s.
- **Accroche :** « Le minage groupé grandit avec ton personnage. »
- **Plans :** cible composée de blocs identiques ; maintien de `Mouse 4` et sélection à la molette ; destruction bornée de la veine.
- **Texte / voix :** « Les rangs passent de 4 à 256 blocs. Portée, permissions, outils, usure et drops restent vérifiés par le serveur. »
- **Sortie :** `SANCTUARY / MINAGE 006`.
- **Source :** [rangs de minage groupé](../../sanctuary/PROGRESSION.md#minage--sept-améliorations).
## 007 — Construire jusqu'à 15 × 15
- **Format / durée :** démonstration, 15 s.
- **Accroche :** « Un clic peut poser un plan entier. »
- **Plans :** prévisualisation 3 × 3 ; sélection d'un rang supérieur à la molette ; placement final d'un plan 15 × 15.
- **Texte / voix :** « `B` et clic droit construisent de 3 × 3 à 15 × 15. Chaque bloc reste placé par une action Minecraft normale. »
- **Sortie :** `SANCTUARY / CONSTRUCTION 007`.
- **Source :** [construction groupée](../../sanctuary/PROGRESSION.md#construction--sept-améliorations).
## 008 — Ta portée devient une statistique
- **Format / durée :** avant / après, 12 s.
- **Accroche :** « Au départ, ce bloc est trop loin. »
- **Plans :** interaction refusée à trois blocs dépassés ; achat de Construction ; même caméra, interaction réussie jusqu'au plafond de 5,5 blocs.
- **Texte / voix :** « La portée de construction progresse. La portée d'attaque, elle, ne change pas. »
- **Sortie :** `SANCTUARY / PROGRESSION 008`.
- **Source :** [rangs de Construction](../../sanctuary/PROGRESSION.md#construction--sept-améliorations).
## 009 — Les bons repas donnent de vrais cœurs temporaires
- **Format / durée :** une règle, 13 s.
- **Accroche :** « Manger peut te protéger sans augmenter ta vie permanente. »
- **Plans :** HUD avant le repas ; consommation ; apparition des cœurs orange ; disparition d'une portion après sa durée.
- **Texte / voix :** « Nutrition, saturation et effets déterminent jusqu'à trois cœurs de repas pendant 15 à 60 secondes. »
- **Sortie :** `SANCTUARY / REPAS 009`.
- **Source :** [cœurs de repas](../../README.md#progression-sanctuary-alpha).
## 010 — Une barre pleine ne suffit pas pour sprinter
- **Format / durée :** problème / règle, 12 s.
- **Accroche :** « Six points de faim. Barre pleine. Sprint impossible. »
- **Plans :** tentative de sprint au rang initial ; achat du premier rang de Faim ; sprint réussi avec plus de six points.
- **Texte / voix :** « Sanctuary garde le seuil absolu de Minecraft. Ton premier rang de Faim débloque réellement la course. »
- **Sortie :** `SANCTUARY / FAIM 010`.
- **Source :** [faim et seuil de sprint](../../sanctuary/PROGRESSION.md#faim--sept-améliorations).
## 011 — Le souffle n'encombre plus l'écran
- **Format / durée :** avant / après, 10 s.
- **Accroche :** « Pourquoi afficher ton air quand tu es sur terre ? »
- **Plans :** HUD terrestre sans bulles ; entrée dans l'eau ; apparition de trois à dix bulles selon le rang.
- **Texte / voix :** « La réserve de souffle n'apparaît que lorsqu'elle devient utile. »
- **Sortie :** `SANCTUARY / HUD 011`.
- **Source :** [HUD de souffle](../../README.md#progression-sanctuary-alpha).
## 012 — L'expérience achète des capacités permanentes
- **Format / durée :** une règle, 14 s.
- **Accroche :** « Dans Sanctuary, l'XP ne sert pas qu'aux enchantements. »
- **Plans :** niveaux disponibles ; écran Progression ; achat du rang suivant ; mort rapide puis retour du rang.
- **Texte / voix :** « Vie, faim, minage, souffle, construction et inventaire progressent avec les niveaux vanilla. Les achats survivent à la mort. »
- **Sortie :** `SANCTUARY / PROGRESSION 012`.
- **Source :** [améliorations et récompenses](../../sanctuary/PROGRESSION.md#4-améliorations-et-récompenses).
## 013 — La progression appartient au serveur
- **Format / durée :** note de laboratoire, 16 s.
- **Accroche :** « Le client demande. Le serveur décide. »
- **Plans :** achat valide ; tentative sans assez d'XP ; action refusée sans désynchronisation.
- **Texte / voix :** « Le prix n'est jamais choisi par le client. Le serveur vérifie le rang, le solde et applique chaque capacité. »
- **Sortie :** `SANCTUARY / LABO 013`.
- **Source :** [contrat serveur de progression](../../README.md#progression-sanctuary-alpha).
## 014 — Une carte composée de 160 000 points
- **Format / durée :** révélation, 15 s.
- **Accroche :** « L'Atlas n'est pas une image vue du ciel. »
- **Plans :** ouverture avec `M` ; rotation lente du nuage de points ; zoom sur le relief d'un chunk.
- **Texte / voix :** « C'est une vue GPU orientable, construite depuis la hauteur et la couleur du terrain exploré. »
- **Sortie :** `SANCTUARY / ATLAS 014`.
- **Source :** [Atlas en nuage de points](../../README.md#progression-sanctuary-alpha).
## 015 — Le monde calculé n'est pas le monde découvert
- **Format / durée :** une règle, 14 s.
- **Accroche :** « L'Atlas connaît les biomes. Pas tes souvenirs. »
- **Plans :** fond de biomes discret ; marche vers une zone inconnue ; apparition du profil détaillé après chargement du chunk.
- **Texte / voix :** « Le fog of war sépare le monde prévisible des lieux que tu as réellement visités. »
- **Sortie :** `SANCTUARY / ATLAS 015`.
- **Source :** [fog of war persistant](../../README.md#progression-sanctuary-alpha).
## 016 — La carte peut exister dans le monde
- **Format / durée :** interaction, 12 s.
- **Accroche :** « Maintiens `Mouse 5`. »
- **Plans :** vue FPS normale ; apparition de la projection Atlas dans le monde ; regard vers l'horizon et disparition progressive.
- **Texte / voix :** « Son opacité suit ton regard pour ne pas masquer le paysage. »
- **Sortie :** `SANCTUARY / ATLAS 016`.
- **Source :** [projection temps réel de l'Atlas](../../README.md#progression-sanctuary-alpha).
## 017 — Une mini-carte à neuf positions
- **Format / durée :** interaction cachée, 13 s.
- **Accroche :** « La même carte peut devenir une mini-carte. »
- **Plans :** activation avec `Mouse 5` ; déplacement entre trois des neuf ancrages ; réglage du zoom à la molette.
- **Texte / voix :** « Permanente, maintenue ou désactivée. Placée là où ton interface en a besoin. »
- **Sortie :** `SANCTUARY / ATLAS 017`.
- **Source :** [modes et placements de mini-carte](../../README.md#progression-sanctuary-alpha).
## 018 — L'Atlas observe les traces du monde
- **Format / durée :** découverte, 15 s.
- **Accroche :** « Certains lieux se cartographient sans marqueur manuel. »
- **Plans :** passage près d'un coffre, d'un portail et d'un générateur ; ouverture de l'Atlas ; trois pictogrammes fixes visibles.
- **Texte / voix :** « Coffres, portails, générateurs et rails souterrains deviennent des points d'intérêt locaux. »
- **Sortie :** `SANCTUARY / ATLAS 018`.
- **Source :** [détection des lieux Atlas](../../README.md#progression-sanctuary-alpha).
## 019 — Maison et Arbre ne sont jamais devinés
- **Format / durée :** une règle, 14 s.
- **Accroche :** « Sanctuary refuse de décider où se trouve ta maison. »
- **Plans :** Personal Computer posé ; clic sur « Maison ici » ; apparition du pictogramme et de son libellé dans l'Atlas.
- **Texte / voix :** « Ces lieux sont personnels. Tu les déclares toi-même depuis ton ordinateur. »
- **Sortie :** `SANCTUARY / ATLAS 019`.
- **Source :** [Personal Computer et lieux personnels](../../README.md#red-stoner-et-photographie).
## 020 — Le monde principal est un archipel flottant
- **Format / durée :** chronique du monde, 18 s.
- **Accroche :** aucun texte pendant les deux premières secondes.
- **Plans :** départ sur l'île centrale ; travelling très lent au-dessus de la couronne de vide ; révélation des îles procédurales extérieures.
- **Texte / voix :** « Sanctuary remplace le monde par défaut : un refuge central, du vide, puis un archipel sans fin. »
- **Sortie :** `SANCTUARY / MONDE 020`.
- **Source :** [génération du monde principal](../../README.md#génération-du-monde-principal).
## 021 — Le vide protège le début de partie
- **Format / durée :** une règle du monde, 15 s.
- **Accroche :** « Cette absence de terrain est volontaire. »
- **Plans :** bord de l'île ; longue focale sur le vide ; îles lointaines reprenant au-delà de la couronne.
- **Texte / voix :** « Le refuge initial est séparé du monde procédural. S'en échapper devient une étape de progression. »
- **Sortie :** `SANCTUARY / MONDE 021`.
- **Source :** [départ de partie](../../sanctuary/PROGRESSION.md#2-départ-de-partie).
## 022 — 108 biomes, sans charger Terralith
- **Format / durée :** preuve visuelle, 18 s.
- **Accroche :** « Sanctuary distribue 108 biomes natifs. »
- **Plans :** trois biomes très contrastés en coupes franches ; survol d'une transition ; écran final sobre avec `108`.
- **Texte / voix :** « Huit biomes historiques et cent créations Terramix, générés sous les règles climatiques natives du pack. »
- **Sortie :** `SANCTUARY / BIOMES 022`.
- **Source :** [biomes natifs d'Another World](../../anotherworld/README.md#biomes-natifs).
## 023 — Le climat dessine des provinces
- **Format / durée :** note de conception, 17 s.
- **Accroche :** « Les biomes ne sont pas tirés au hasard chunk par chunk. »
- **Plans :** travelling continu traversant une zone chaude et sèche ; transition vers une zone humide ; carte large montrant les grands contours.
- **Texte / voix :** « Température et humidité forment de grandes provinces. Une variation locale casse ensuite leurs frontières. »
- **Sortie :** `SANCTUARY / CLIMAT 023`.
- **Source :** [prototype climatique de l'alpha.189](../../CHANGELOG.md#2620-alpha189).
## 024 — Les Sulfur Caves s'annoncent dans la pierre
- **Format / durée :** découverte, 15 s.
- **Accroche :** « Avant la caverne, la roche te prévient. »
- **Plans :** tunnel de deepslate ; première amorce de soufre ; révélation des piscines et pointes de la Sulfur Cave.
- **Texte / voix :** « Une trace visible précède la formation principale. L'exploration peut se lire dans les parois. »
- **Sortie :** `SANCTUARY / CAVERNES 024`.
- **Source :** [Sulfur Caves de l'alpha.179](../../CHANGELOG.md#2620-alpha179).
## 025 — Un donjon peut être suspendu sous une île
- **Format / durée :** chronique du monde, 17 s.
- **Accroche :** « Certains donjons n'ont pas de sol. »
- **Plans :** île flottante vue de côté ; descente verticale ; révélation d'un donjon de cobblestone accroché par des chaînes.
- **Texte / voix :** « S'il trouve de la matière au-dessus, le donjon s'y suspend. Sans ancrage, il n'apparaît pas. »
- **Sortie :** `SANCTUARY / DONJONS 025`.
- **Source :** [donjons suspendus de l'alpha.188](../../CHANGELOG.md#2620-alpha188).
## 026 — Une ravine tapissée de seize couleurs
- **Format / durée :** révélation, 14 s.
- **Accroche :** « Ce biome est volontairement presque introuvable. »
- **Plans :** marche dans un paysage sombre ; arrivée au bord ; plongée lente dans une Rainbow Ravine verticale.
- **Texte / voix :** « Les seize blocs arc-en-ciel suivent les entailles irrégulières du relief, du sommet jusqu'au fond. »
- **Sortie :** `SANCTUARY / BIOMES 026`.
- **Source :** [Rainbow Ravine de l'alpha.114](../../CHANGELOG.md#2620-alpha114).
## 027 — Un monde Minecraft fini en 128 × 128 × 64
- **Format / durée :** chronique historique, 18 s.
- **Accroche :** « Ce monde possède une vraie fin. »
- **Plans :** panorama de l'île Indev ; plage, caverne et maison en coupes courtes ; approche de la limite extérieure.
- **Texte / voix :** « Alpha reconstruit un volume déterministe de 128 × 128 × 64 blocs. Rien ne se génère au-delà. »
- **Sortie :** `SANCTUARY / ALPHA 027`.
- **Source :** [contrat de génération Alpha](../../sanctuary/ALPHA.md#contrat-de-génération).
## 028 — Le portail d'Alpha se construit en cobblestone moussue
- **Format / durée :** interaction, 15 s.
- **Accroche :** « Quatre sur cinq. Deux sur trois à l'intérieur. »
- **Plans :** construction du cadre extérieur 4 × 5 ; allumage au briquet ; arrivée sûre hors de la maison Indev.
- **Texte / voix :** « Le serveur valide le cadre, la dimension et l'arrivée. La première traversée crée une paire persistante. »
- **Sortie :** `SANCTUARY / PORTAIL 028`.
- **Source :** [accès à Alpha](../../sanctuary/ALPHA.md#accès).
## 029 — Les textures Alpha n'existent que dans Alpha
- **Format / durée :** avant / après, 16 s.
- **Accroche :** « Un resource pack peut appartenir à une dimension. »
- **Plans :** monde normal avec la pile habituelle ; traversée vers Alpha et rechargement ; retour et restauration exacte de l'apparence précédente.
- **Texte / voix :** « Ambiance compose Golden Days Base puis Alpha uniquement pendant la visite, sans redistribuer leurs fichiers. »
- **Sortie :** `SANCTUARY / AMBIANCE 029`.
- **Source :** [composition Golden Days](../../ambiance/GOLDEN_DAYS.md).
## 030 — Notch habite encore dans la maison Indev
- **Format / durée :** chronique, 16 s.
- **Accroche :** « Dans ce monde, Notch est un résident. Pas un spawn aléatoire. »
- **Plans :** approche silencieuse de la maison ; Notch à l'intérieur ; même maison après un redémarrage de la scène.
- **Texte / voix :** « Il existe en un seul exemplaire persistant. S'il disparaît, Sanctuary sait le ramener chez lui. »
- **Sortie :** `SANCTUARY / ALPHA 030`.
- **Source :** [résident d'Alpha](../../sanctuary/ALPHA.md#résident-et-jalon-mondial).
## 031 — La première victoire crée un seul Magic Carpet
- **Format / durée :** récompense, 18 s.
- **Accroche :** « Ce coffre n'existe qu'après la première victoire mondiale. »
- **Plans :** fin du combat de Notch ; feu d'artifice près de la maison ; ouverture du coffre avec le tapis placé au centre.
- **Texte / voix :** « La réservation et la livraison sont sauvegardées séparément : même un redémarrage ne duplique pas la récompense. »
- **Sortie :** `SANCTUARY / RÉCOMPENSE 031`.
- **Source :** [première récompense du Magic Carpet](../../iliketomoveit/MAGIC_CARPET.md#première-récompense).
## 032 — Une Waystone doit être découverte physiquement
- **Format / durée :** une règle, 13 s.
- **Accroche :** « Poser une pierre ne suffit pas pour la connaître. »
- **Plans :** menu sans destination ; arrivée à pied et contact avec la pierre ; destination maintenant visible.
- **Texte / voix :** « Chaque joueur possède son propre réseau de lieux réellement visités. »
- **Sortie :** `SANCTUARY / WAYSTONE 032`.
- **Source :** [Waystones locales](../../README.md#waystones-locaux).
## 033 — Le voyage coûte la distance parcourue
- **Format / durée :** une règle, 12 s.
- **Accroche :** « Une téléportation lointaine ne coûte pas comme un saut de voisinage. »
- **Plans :** deux destinations à des distances différentes ; affichage des coûts ; voyage vers la plus lointaine.
- **Texte / voix :** « Le serveur facture un niveau d'XP par tranche commencée de 512 blocs et recherche une arrivée sûre. »
- **Sortie :** `SANCTUARY / WAYSTONE 033`.
- **Source :** [coût des Waystones](../../README.md#waystones-locaux).
## 034 — Une pierre peut être renommée sans commande
- **Format / durée :** interaction cachée, 10 s.
- **Accroche :** « Ouvre une Waystone. Appuie sur `R`. »
- **Plans :** ouverture du réseau ; saisie d'un nom court ; validation avec `Entrée` puis affichage immédiat.
- **Texte / voix :** « `Échap` annule. Le nouveau nom reste attaché à la pierre. »
- **Sortie :** `SANCTUARY / WAYSTONE 034`.
- **Source :** [renommage des Waystones](../../README.md#waystones-locaux).
## 035 — Un ascenseur peut descendre vers l'inconnu
- **Format / durée :** interaction cachée, 13 s.
- **Accroche :** « Tu peux descendre vers un étage que tu n'as jamais visité. »
- **Plans :** deux Waystones alignées en X/Z ; accroupissement sur celle du haut ; arrivée en bas et découverte de la destination.
- **Texte / voix :** « La montée reste interdite tant que l'étage supérieur n'a pas été découvert. »
- **Sortie :** `SANCTUARY / ASCENSEUR 035`.
- **Source :** [ascenseur vertical Waystone](../../README.md#waystones-locaux).
## 036 — Le grappin conserve ton élan
- **Format / durée :** démonstration, 14 s.
- **Accroche :** « Ce grappin ne te téléporte pas vers son crochet. »
- **Plans :** lancement ; balancement en arc lisible ; lâcher au point bas et conservation de la vitesse.
- **Texte / voix :** « Charge, longueur de corde, rappel, descente et restitution de l'élan composent le mouvement. »
- **Sortie :** `SANCTUARY / GRAPPIN 036`.
- **Source :** [grappin et poulies](../../README.md#grappin-et-poulies).
## 037 — Un grappin fluide, même quand le serveur décide
- **Format / durée :** problème / Sanctuary, 16 s.
- **Accroche :** « En multijoueur, un grappin peut devenir une suite de corrections. »
- **Plans :** capture authentique d'un ancien mouvement saccadé si disponible ; version actuelle fluide ; très bref arrêt sur la position réconciliée.
- **Texte / voix :** « Le client prédit le même pendule. Le serveur reste l'autorité et ne corrige que dans une limite bornée. »
- **Sortie :** `SANCTUARY / LABO 037`.
- **Source :** [prédiction du grappin, alpha.196](../../CHANGELOG.md#2620-alpha196).
## 038 — Deux poulies créent une tyrolienne
- **Format / durée :** démonstration, 12 s.
- **Accroche :** « Deux points. Jusqu'à 128 blocs. »
- **Plans :** liaison des deux têtes ; vue latérale de la ligne ; traversée complète à vitesse constante.
- **Texte / voix :** « Les collisions sont vérifiées pendant tout le trajet. »
- **Sortie :** `SANCTUARY / POULIE 038`.
- **Source :** [poulies et tyrolienne](../../README.md#grappin-et-poulies).
## 039 — La tyrolienne peut piloter une machine
- **Format / durée :** interaction entre systèmes, 12 s.
- **Accroche :** « Le passage d'un joueur devient un signal. »
- **Plans :** lampe redstone éteinte reliée à la poulie ; joueur traversant ; signal 15 et lampe allumée pendant le passage.
- **Texte / voix :** « Le déplacement peut devenir une entrée d'automatisation. »
- **Sortie :** `SANCTUARY / SYSTÈMES 039`.
- **Source :** [signal redstone des poulies](../../README.md#grappin-et-poulies).
## 040 — Un Creeper peut naître avec sept apparences
- **Format / durée :** collection, 15 s.
- **Accroche :** « C'est toujours le Creeper vanilla. »
- **Plans :** même cadre, coupes rapides entre classic, simple, pig, business, better, shiny et starry night ; arrêt sur le dernier.
- **Texte / voix :** « L'entité, la charge et l'œuf restent vanilla. L'apparence et le tempérament deviennent persistants. »
- **Sortie :** `SANCTUARY / CREEPER 040`.
- **Source :** [apparences de Creepers](../../itsalive/README.md#creepers).
## 041 — Une fleur peut rendre un Creeper heureux
- **Format / durée :** interaction, 11 s.
- **Accroche :** « Essaie de lui donner une fleur. »
- **Plans :** approche dangereuse ; remise de la fleur ; fleur visible sur la tête et Creeper suivant le joueur.
- **Texte / voix :** « Le changement n'est pas cosmétique : son tempérament devient heureux. »
- **Sortie :** `SANCTUARY / CREEPER 041`.
- **Source :** [tempérament heureux](../../itsalive/README.md#creepers).
## 042 — Le bonheur d'un Creeper est réversible
- **Format / durée :** interaction cachée, 10 s.
- **Accroche :** « Tu peux reprendre la fleur. »
- **Plans :** Creeper heureux ; tonte ; fleur rendue au joueur et retour immédiat du tempérament dangereux.
- **Texte / voix :** « Rien n'est supprimé silencieusement. Même la fleur revient. »
- **Sortie :** `SANCTUARY / CREEPER 042`.
- **Source :** [tonte du Creeper](../../itsalive/README.md#creepers).
## 043 — Une poule sur 256 peut pondre ceci
- **Format / durée :** découverte rare, 12 s.
- **Accroche :** plan fixe sur l'œuf doré, sans texte pendant une seconde.
- **Plans :** poule vanilla ; ponte d'un œuf doré mise en scène avec une capture authentique ou une reproduction de laboratoire clairement identifiée ; lancer de l'œuf.
- **Texte / voix :** « Une poule vanilla possède une chance sur 256 de pondre l'entrée vers une nouvelle lignée. »
- **Sortie :** `SANCTUARY / ŒUF DORÉ 043`.
- **Source :** [œuf doré et poules à gemmes](../../itsalive/README.md#poules-à-gemmes).
## 044 — Les poules peuvent pondre des gemmes
- **Format / durée :** collection, 15 s.
- **Accroche :** « Verte, rouge ou bleue. Trois cadences différentes. »
- **Plans :** bébé issu de l'œuf doré ; trois variantes adultes ; émeraude, rubis et saphir pondus en coupes distinctes.
- **Texte / voix :** « L'émeraude est la plus rapide. Le saphir, la plus lente. »
- **Sortie :** `SANCTUARY / POULLES 044`.
- **Source :** [variantes et cadences des poules à gemmes](../../itsalive/README.md#poules-à-gemmes).
## 045 — Une vache sur 64 se tient debout
- **Format / durée :** découverte absurde, 11 s.
- **Accroche :** plan fixe d'une vache normale qui sort du cadre, puis révélation de la vache debout.
- **Plans :** contraste des deux silhouettes ; marche de la variante ; plan final frontal.
- **Texte / voix :** « La mutation est rare, persistante et peut se transmettre par l'élevage. »
- **Sortie :** `SANCTUARY / VACHE 045`.
- **Source :** [vaches debout rares](../../itsalive/README.md#vaches-debout-rares).
## 046 — Chaque villageois reçoit un nom stable
- **Format / durée :** une règle, 12 s.
- **Accroche :** « Ce villageois ne changera pas de nom au redémarrage. »
- **Plans :** trois villageois avec noms différents ; sortie puis retour dans le monde ; mêmes noms visibles.
- **Texte / voix :** « Le nom vient de son UUID. Un nom donné manuellement n'est jamais remplacé. »
- **Sortie :** `SANCTUARY / VILLAGE 046`.
- **Source :** [villageois nommés](../../itsalive/README.md#villageois-nommés).
## 047 — Il existe seize Mooblooms
- **Format / durée :** collection, 16 s.
- **Accroche :** « Une seule créature. Seize fleurs. »
- **Plans :** travelling devant une ligne de variantes ; gros plan sur les fleurs du dos ; gros plan sur celle qui suit la tête.
- **Texte / voix :** « Chaque variante est synchronisée, sauvegardée et possède son propre œuf de test. »
- **Sortie :** `SANCTUARY / MOOBLOOM 047`.
- **Source :** [seize variantes de Moobloom](../../itsalive/README.md#mooblooms).
## 048 — Un veau Moobloom hérite d'un de ses parents
- **Format / durée :** expérience, 13 s.
- **Accroche :** « Deux fleurs différentes. Une seule pour le veau. »
- **Plans :** deux parents de variantes distinctes ; reproduction ; révélation de la variante héritée.
- **Texte / voix :** « Le veau choisit aléatoirement la fleur de l'un de ses deux parents. »
- **Sortie :** `SANCTUARY / HÉRITAGE 048`.
- **Source :** [élevage des Mooblooms](../../itsalive/README.md#mooblooms).
## 049 — Les papillons se nourrissent de fleurs
- **Format / durée :** chronique calme, 14 s.
- **Accroche :** aucun texte pendant le premier vol.
- **Plans :** groupe de papillons autour d'un biome fleuri ; joueur tenant une fleur ; reproduction puis réaction agressive après une attaque.
- **Texte / voix :** « Ils suivent les fleurs, se reproduisent avec elles et se défendent lorsqu'on les attaque. »
- **Sortie :** `SANCTUARY / PAPILLONS 049`.
- **Source :** [papillons](../../itsalive/README.md#papillons).
## 050 — Le Ghost ne reste jamais jusqu'au matin
- **Format / durée :** horreur minimale, 15 s.
- **Accroche :** murmure hors champ, sans texte.
- **Plans :** Ghost traversant un mur ; charge courte vers le joueur ; lever du jour et disparition.
- **Texte / voix :** « La nuit, il hante, murmure, frappe, puis disparaît. Le jour le supprime. »
- **Sortie :** `SANCTUARY / GHOST 050`.
- **Source :** [Real Ghost](../../itsalive/README.md#real-ghost).
## 051 — Bracken recule quand tu le regardes
- **Format / durée :** horreur minimale, 15 s.
- **Accroche :** « Ne détourne pas les yeux. Mais ne le fixe pas trop longtemps. »
- **Plans :** Bracken approchant dans le dos ; regard du joueur et recul ; rage après un regard prolongé.
- **Texte / voix :** « Il préfère attaquer par derrière. Les haches et le feu lui font davantage de dégâts. »
- **Sortie :** `SANCTUARY / BRACKEN 051`.
- **Source :** [comportement de Bracken](../../itsalive/README.md#real-ghost).
## 052 — Sanctuary supprime les Phantoms
- **Format / durée :** une règle, 9 s.
- **Accroche :** « Les Phantoms n'existent pas ici. »
- **Plans :** nuit après plusieurs jours sans dormir ; ciel vide ; tentative de chargement d'un Phantom immédiatement supprimé en laboratoire.
- **Texte / voix :** « C'est la politique héritée de Real Ghost et No Phantoms. »
- **Sortie :** `SANCTUARY / RÈGLE 052`.
- **Source :** [suppression des Phantoms](../../itsalive/README.md#real-ghost).
## 053 — Une baleine de 36 blocs traverse le ciel
- **Format / durée :** chronique du monde, 18 s.
- **Accroche :** garder les deux premières secondes entièrement silencieuses.
- **Plans :** ombre passant sur le terrain ; révélation latérale de la baleine ; plan large avec son sillage nuageux.
- **Texte / voix :** « Elle est géante, intangible, invulnérable et ne donne aucun butin. Elle existe seulement pour traverser le monde. »
- **Sortie :** `SANCTUARY / BALEINE 053`.
- **Source :** [baleine volante géante](../../itsalive/README.md#baleine-volante-géante).
## 054 — Cinq Infectés, une seule entité persistante
- **Format / durée :** collection, 15 s.
- **Accroche :** « Rouge, jaune, orange, violet, vert. »
- **Plans :** ligne des cinq variantes ; gros plans sur les t-shirts ; attaque simultanée puis coupe noire.
- **Texte / voix :** « Infecté, vomiteur, cracheur, hunter et chargeur partagent la même base, mais pas le même comportement. »
- **Sortie :** `SANCTUARY / INFECTÉS 054`.
- **Source :** [cinq variantes d'Infectés](../../itsalive/README.md#infectés).
## 055 — Le Vomiteur transforme la vision en danger
- **Format / durée :** démonstration, 13 s.
- **Accroche :** attaque reçue avant l'apparition du texte.
- **Plans :** Vomiteur ; overlay de projections ; désorientation et monstres proches attirés vers le joueur.
- **Texte / voix :** « Neuf secondes de vision masquée. Douze secondes de désorientation. Et le bruit attire les monstres. »
- **Sortie :** `SANCTUARY / INFECTÉS 055`.
- **Source :** [attaque du Vomiteur](../../itsalive/README.md#infectés).
## 056 — Le Cracheur combat à distance
- **Format / durée :** démonstration, 11 s.
- **Accroche :** « Celui-ci n'a pas besoin de te toucher. »
- **Plans :** projectile acide en trajectoire ; impact ; effets Poison et Lenteur visibles.
- **Texte / voix :** « Une boule d'acide blesse, empoisonne et ralentit. »
- **Sortie :** `SANCTUARY / INFECTÉS 056`.
- **Source :** [attaque du Cracheur](../../itsalive/README.md#infectés).
## 057 — Quatre pressions pour repousser le Hunter
- **Format / durée :** règle de survie, 16 s.
- **Accroche :** écran noir au moment de la capture.
- **Plans :** bond corrigé vers une cible mobile ; Hunter assis sur la tête ; quatre pressions distinctes d'accroupissement puis libération.
- **Texte / voix :** « Il verrouille sa victime pendant six secondes. Résister quatre fois le repousse. »
- **Sortie :** `SANCTUARY / INFECTÉS 057`.
- **Source :** [capture du Hunter](../../itsalive/README.md#infectés).
## 058 — Le Chargeur devient vulnérable après l'impact
- **Format / durée :** règle de combat, 12 s.
- **Accroche :** « Esquiver est aussi une attaque. »
- **Plans :** préparation lisible ; charge et esquive latérale ; courte récupération pendant laquelle le joueur riposte.
- **Texte / voix :** « Il percute et repousse. Puis il doit récupérer. »
- **Sortie :** `SANCTUARY / INFECTÉS 058`.
- **Source :** [comportement du Chargeur](../../itsalive/README.md#infectés).
## 059 — Un œuf équipé devient un pouvoir
- **Format / durée :** manifeste de système, 16 s.
- **Accroche :** « Les œufs d'apparition ne servent plus seulement à faire apparaître des créatures. »
- **Plans :** œuf équipé dans le slot de familier ; apparition du familier visuel ; activation avec `G` de trois pouvoirs contrastés en coupes rapides.
- **Texte / voix :** « Une essence à la fois. Un passif. Un actif choisi et validé par le serveur. »
- **Sortie :** `SANCTUARY / FAMILIERS 059`.
- **Source :** [contrat des pouvoirs de familiers](../../sanctuary/COMPANION_POWERS.md#contrat-de-jeu).
## 060 — Certaines cultures poussent sur deux blocs
- **Format / durée :** time-lapse, 12 s.
- **Accroche :** « Le maïs ne reste pas une texture plate. »
- **Plans :** plant bas ; accéléré des huit stades ; maïs, tomate ou concombre devenant réellement haut de deux blocs.
- **Texte / voix :** « La moitié supérieure reste synchronisée avec le plant inférieur. »
- **Sortie :** `SANCTUARY / AGRICULTURE 060`.
- **Source :** [agriculture It's Alive](../../itsalive/README.md#agriculture).
## 061 — Récolter sans arracher la plante
- **Format / durée :** interaction, 11 s.
- **Accroche :** « Certaines récoltes sont pérennes. »
- **Plans :** plant mûr de tomate ; clic de récolte donnant les fruits ; retour visuel du plant à un stade plus jeune.
- **Texte / voix :** « Fraise, tomate, concombre, poivron, aubergine et raisin repoussent après la récolte. »
- **Sortie :** `SANCTUARY / AGRICULTURE 061`.
- **Source :** [cultures pérennes](../../itsalive/README.md#agriculture).
## 062 — Le riz exige une vraie rizière
- **Format / durée :** une règle, 12 s.
- **Accroche :** « Pas de riz sur une simple terre labourée. »
- **Plans :** plantation refusée sur une parcelle ordinaire ; source d'eau pleine posée sur de la boue ; croissance complète du riz immergé.
- **Texte / voix :** « Le décor et la règle agricole racontent la même chose. »
- **Sortie :** `SANCTUARY / CUISINE 062`.
- **Source :** [riz immergé](../../itsalive/FOOD_WIKI.md#riz--une-vraie-rizière).
## 063 — Le haricot change selon le moment de récolte
- **Format / durée :** comparaison, 12 s.
- **Accroche :** « Récolté jeune ou récolté sec, ce n'est pas le même ingrédient. »
- **Plans :** récolte d'un plant jeune ; même cadrage sur le plant mûr ; comparaison des deux objets.
- **Texte / voix :** « Le temps de récolte devient un choix culinaire. »
- **Sortie :** `SANCTUARY / CUISINE 063`.
- **Source :** [haricot vert ou sec](../../itsalive/FOOD_WIKI.md#haricot--vert-ou-sec).
## 064 — Six machines, six gestes culinaires
- **Format / durée :** collection, 18 s.
- **Accroche :** « Presser. Moudre. Poêler. Bouillir. Cuire. Fermenter. »
- **Plans :** une action lisible par poste, toujours avec la même caméra et la même table de travail ; résultat final aligné.
- **Texte / voix :** « La cuisine de Sanctuary n'est pas un unique menu de recettes. »
- **Sortie :** `SANCTUARY / CUISINE 064`.
- **Source :** [postes de cuisine](../../itsalive/FOOD_WIKI.md#postes-de-cuisine).
## 065 — Le livre culinaire possède 64 pages à découvrir
- **Format / durée :** découverte, 15 s.
- **Accroche :** « Les recettes ne sont pas toutes connues au départ. »
- **Plans :** livre presque vide ; obtention d'une page ; nouvelle recette révélée ; livre posé sur un pupitre et lu par un autre joueur.
- **Texte / voix :** « Le catalogue est personnel dans l'inventaire et temporairement partageable sur un pupitre. »
- **Sortie :** `SANCTUARY / LIVRE 065`.
- **Source :** [64 pages et partage du livre](../../itsalive/FOOD_WIKI.md#les-64-pages-de-recettes).
## 066 — Canaplia possède seize familles
- **Format / durée :** collection, 16 s.
- **Accroche :** « Seize plantes. Seize découvertes. Seize effets. »
- **Plans :** grille ou travelling de cultures matures ; trois têtes et résines en gros plan ; Livre interdit refermé.
- **Texte / voix :** « Chaque famille possède sa culture, sa progression et son profil serveur. »
- **Sortie :** `SANCTUARY / CANAPLIA 066`.
- **Source :** [migration Canaplia](../../CANAPLIA.md).
## 067 — Le climat décide où découvrir Canaplia
- **Format / durée :** exploration, 15 s.
- **Accroche :** « Toutes les plantes ne peuvent pas apparaître partout. »
- **Plans :** recherche dans un biome incompatible ; voyage vers le bon climat ; découverte de la variante sauvage.
- **Texte / voix :** « Les sources sont distribuées par climat. Ember reste au Nether. Indigo reste dans l'End. »
- **Sortie :** `SANCTUARY / CANAPLIA 067`.
- **Source :** [découvertes climatiques de l'alpha.208](../../CHANGELOG.md#2620-alpha208).
## 068 — Une hybridation vérifie réellement ses parents
- **Format / durée :** expérience, 15 s.
- **Accroche :** « Deux plantes côte à côte ne suffisent pas. »
- **Plans :** hybridation refusée dans de mauvaises conditions ; correction de l'humidité, de la lumière ou de l'heure ; résultat obtenu.
- **Texte / voix :** « Le serveur revalide les parents matures, l'eau, le climat, la lumière et le moment. »
- **Sortie :** `SANCTUARY / CANAPLIA 068`.
- **Source :** [six hybridations validées côté serveur](../../CHANGELOG.md#2620-alpha208).
## 069 — Dix têtes deviennent une résine
- **Format / durée :** transformation, 12 s.
- **Accroche :** « Dix têtes identiques. Une seule résine. »
- **Plans :** dix objets réunis ; transformation ; application et aura persistante visualisée sobrement.
- **Texte / voix :** « L'aura dure 90 secondes et reste attachée à son état serveur. »
- **Sortie :** `SANCTUARY / CANAPLIA 069`.
- **Source :** [résines Canaplia de l'alpha.208](../../CHANGELOG.md#2620-alpha208).
## 070 — Un bad trip doit rester accessible
- **Format / durée :** note de conception, 16 s.
- **Accroche :** « Un effet psychédélique ne doit pas rendre le jeu injouable. »
- **Plans :** profil normal ; profil bad trip à intensité modérée ; écran des réglages montrant réduction des mouvements, wobble et ghosting.
- **Texte / voix :** « Ambiance compose un seul effet à la fois et laisse réduire ses mouvements visuels. »
- **Sortie :** `SANCTUARY / ACCESSIBILITÉ 070`.
- **Source :** [profils Canaplia et réglages Ambiance](../../CHANGELOG.md#2620-alpha208).
## 071 — Le Livre interdit est personnel, puis partageable
- **Format / durée :** interaction, 14 s.
- **Accroche :** « Ton livre connaît tes découvertes. Pas celles de tout le serveur. »
- **Plans :** deux joueurs avec catalogues différents ; pose sur pupitre ; consultation temporaire par le second joueur ; retrait du livre.
- **Texte / voix :** « Sur un pupitre, le savoir se partage. Dans l'inventaire, il redevient personnel. »
- **Sortie :** `SANCTUARY / CANAPLIA 071`.
- **Source :** [Livre interdit de Canaplia](../../CANAPLIA.md).
## 072 — Le ciel suit l'heure réelle du serveur
- **Format / durée :** time-lapse, 15 s.
- **Accroche :** « Ici, midi dans le jeu peut être midi dehors. »
- **Plans :** horloge système ou carton d'heure sobre ; ciel correspondant ; accéléré contrôlé montrant la continuité du cycle.
- **Texte / voix :** « Ambiance peut synchroniser le cycle du ciel sur le fuseau du serveur ou sur un décalage UTC fixe. »
- **Sortie :** `SANCTUARY / TEMPS 072`.
- **Source :** [runtime Realtime de l'alpha.126](../../CHANGELOG.md#2620-alpha126).
## 073 — Le calendrier commence le 17 mai 2009
- **Format / durée :** détail d'auteur, 11 s.
- **Accroche :** « Le premier jour de ce monde a une date. »
- **Plans :** joueur tenant une horloge ; apparition du HUD calendrier ; gros plan sur la date et le mois.
- **Texte / voix :** « L'époque minecraftienne de Sanctuary commence le 17 mai 2009 et possède douze mois. »
- **Sortie :** `SANCTUARY / CALENDRIER 073`.
- **Source :** [calendrier Realtime](../../CHANGELOG.md#2620-alpha126).
## 074 — La météo de la journée est déterministe
- **Format / durée :** une règle du monde, 15 s.
- **Accroche :** « La météo n'est choisie qu'une fois par jour. »
- **Plans :** matin clair ; transition vers brouillard ou pluie légère ; consultation d'un appareil de prévision.
- **Texte / voix :** « Éclaircies, ciel couvert, brouillard, pluie légère ou pluie normale composent le profil quotidien. »
- **Sortie :** `SANCTUARY / MÉTÉO 074`.
- **Source :** [profils météo de l'alpha.128](../../CHANGELOG.md#2620-alpha128) ; [appareils de prévision de l'alpha.131](../../CHANGELOG.md#2620-alpha131).
## 075 — Une pleine lune sur quatre devient rouge
- **Format / durée :** chronique nocturne, 16 s.
- **Accroche :** lune blanche pendant deux secondes, puis coupe vers la lune de sang.
- **Plans :** pleine lune et nuit blanche ; autre cycle en rouge ; plan du paysage sous la nouvelle ambiance.
- **Texte / voix :** « Le cycle vanilla reste sur huit jours. La première nuit de sang arrive au jour 24. »
- **Sortie :** `SANCTUARY / LUNE 075`.
- **Source :** [cycle lunaire de l'alpha.128](../../CHANGELOG.md#2620-alpha128).
## 076 — Le premier jour refuse la mauvaise météo
- **Format / durée :** une règle du monde, 10 s.
- **Accroche :** « Le premier jour doit rester lisible. »
- **Plans :** première apparition ; ciel clair ; accéléré jusqu'au coucher sans pluie ni brouillard.
- **Texte / voix :** « Le jour zéro est toujours clair. L'orage est rare et interdit pendant cette première journée. »
- **Sortie :** `SANCTUARY / MÉTÉO 076`.
- **Source :** [premier jour de l'alpha.128](../../CHANGELOG.md#2620-alpha128).
## 077 — Tout bloc lumineux tenu éclaire réellement
- **Format / durée :** avant / après, 12 s.
- **Accroche :** « La torche n'a pas besoin d'être posée. »
- **Plans :** grotte noire ; torche prise en main principale ; deuxième lumière tenue en main secondaire avec le terrain et les entités éclairés.
- **Texte / voix :** « La lumière locale reprend le niveau vanilla du bloc et décroît d'un niveau par bloc. »
- **Sortie :** `SANCTUARY / LUMIÈRE 077`.
- **Source :** [Dynamic Torches de l'alpha.130](../../CHANGELOG.md#2620-alpha130).
## 078 — La pluie ne doit pas simplement assombrir l'image
- **Format / durée :** avant / après conceptuel, 14 s.
- **Accroche :** « Une météo différente n'a pas besoin d'un filtre gris. »
- **Plans :** paysage normal ; même cadrage sous pluie légère ; même cadrage sous orage avec seulement la portée du brouillard transformée.
- **Texte / voix :** « Les profils météo modifient la visibilité sans écraser la colorimétrie du monde. »
- **Sortie :** `SANCTUARY / AMBIANCE 078`.
- **Source :** [signature visuelle de l'alpha.129](../../CHANGELOG.md#2620-alpha129).
## 079 — Un shader remplace proprement l'étalonnage final
- **Format / durée :** note de laboratoire, 14 s.
- **Accroche :** « Deux traitements d'image superposés donnent rarement une meilleure image. »
- **Plans :** Ambiance sans shader ; activation d'Iris ; rendu final sans double étalonnage.
- **Texte / voix :** « Quand Iris est actif, son traitement final remplace celui d'Ambiance au lieu de s'y empiler. »
- **Sortie :** `SANCTUARY / LABO 079`.
- **Source :** [compatibilité Iris de l'alpha.129](../../CHANGELOG.md#2620-alpha129).
## 080 — L'appareil photo capture l'image après le shader
- **Format / durée :** problème / Sanctuary, 14 s.
- **Accroche :** « Une photo doit ressembler à ce que le joueur voit. »
- **Plans :** scène avec shader ; visée Canon ; carte photo finale comparée au cadre avant le HUD.
- **Texte / voix :** « Canon capture après la composition finale d'Iris et avant l'interface. »
- **Sortie :** `SANCTUARY / PHOTO 080`.
- **Source :** [capture Canon compatible shaders](../../CHANGELOG.md#2620-alpha129).
## 081 — Le disque n'était pas trop grave à cause du pitch
- **Format / durée :** bug / correction, 18 s.
- **Accroche :** « Ce disque jouait trop lentement et trop grave. »
- **Plans :** extrait de test autorisé du symptôme historique ; carton très bref `stéréo 48 kHz → mono` ; lecture corrigée au même endroit.
- **Texte / voix :** « La source stéréo était mal convertie. Sanctuary moyenne maintenant chaque paire gauche-droite, avec une vitesse OpenAL normale. »
- **Sortie :** `SANCTUARY / LABO 081`.
- **Source :** [correction audio de l'alpha.221](../../CHANGELOG.md#2620-alpha221).
## 082 — Une demi-dalle peut ouvrir 128 conteneurs
- **Format / durée :** révélation fonctionnelle, 15 s.
- **Accroche :** « Ce terminal ne contient aucun objet. »
- **Plans :** terminal fixé sur un coffre ; recul révélant le réseau contigu ; ouverture de la grille agrégée.
- **Texte / voix :** « Il donne accès à un réseau borné de 128 coffres, coffres piégés ou barils chargés. »
- **Sortie :** `SANCTUARY / STOCKAGE 082`.
- **Source :** [terminal Red-Stoner de l'alpha.218](../../CHANGELOG.md#2620-alpha218).
## 083 — Le terminal affiche 54 types par page
- **Format / durée :** interaction, 12 s.
- **Accroche :** « Une page. Cinquante-quatre types. »
- **Plans :** première page ; défilement ; changement de page en conservant l'ordre des emplacements.
- **Texte / voix :** « La grille agrège les types d'objets, pas les cases physiques des coffres. »
- **Sortie :** `SANCTUARY / STOCKAGE 083`.
- **Source :** [pagination du terminal, alpha.218](../../CHANGELOG.md#2620-alpha218).
## 084 — La recherche du terminal comprend Unicode
- **Format / durée :** démonstration d'interface, 13 s.
- **Accroche :** « Accents et alphabets Unicode restent recherchables. »
- **Plans :** saisie d'un nom Unicode réellement présent dans le catalogue ; filtrage instantané ; retrait d'une pile sans déplacement des résultats restants.
- **Texte / voix :** « Les quantités deviennent compactes et les emplacements ne glissent plus lorsqu'une pile est vidée. »
- **Sortie :** `SANCTUARY / STOCKAGE 084`.
- **Source :** [recherche et stabilité du terminal, alpha.222](../../CHANGELOG.md#2620-alpha222).
## 085 — Le terminal se comporte comme un vrai conteneur
- **Format / durée :** problème / Sanctuary, 15 s.
- **Accroche :** « Pas de bouton propriétaire pour fermer. Pas de clics réinventés. »
- **Plans :** clic gauche ; clic droit ; shift-clic depuis une sixième rangée d'inventaire ; fermeture vanilla.
- **Texte / voix :** « Les gestes de Minecraft fonctionnent sur toutes les rangées débloquées. Le serveur décide où déposer chaque pile. »
- **Sortie :** `SANCTUARY / STOCKAGE 085`.
- **Source :** [menu de conteneur du terminal, alpha.220](../../CHANGELOG.md#2620-alpha220).
## 086 — Un clic molette trie sans toucher à la hotbar
- **Format / durée :** interaction cachée, 10 s.
- **Accroche :** « Ouvre un coffre. Clique sur la molette. »
- **Plans :** coffre volontairement désordonné ; clic molette ; rangement immédiat, puis gros plan prouvant que la hotbar n'a pas bougé.
- **Texte / voix :** « Coffres, barils, Shulkers, entonnoirs, distributeurs et inventaire joueur utilisent la même règle. »
- **Sortie :** `SANCTUARY / RANGEMENT 086`.
- **Source :** [tri natif de l'alpha.224](../../CHANGELOG.md#2620-alpha224).
## 087 — Un ordinateur Minecraft avec six ports redstone
- **Format / durée :** démonstration technique, 17 s.
- **Accroche :** « Ce bloc exécute réellement un programme assembleur. »
- **Plans :** saisie d'un programme court ; impulsion sur un port directionnel ; résultat sur lampe et comparateur.
- **Texte / voix :** « Programme borné, RAM, inventaire, six ports, impulsions et sortie comparateur : la machine virtuelle historique reste jouable. »
- **Sortie :** `SANCTUARY / RED-STONER 087`.
- **Source :** [ordinateur 8 bits](../../README.md#red-stoner-et-photographie).
## 088 — Seize pellicules, une photo sur carte
- **Format / durée :** collection, 16 s.
- **Accroche :** « L'appareil ne crée pas un fichier hors du jeu. »
- **Plans :** choix de trois pellicules contrastées parmi les seize ; visée ; trois cartes photo exposées côte à côte.
- **Texte / voix :** « Canon transforme la scène en objet Minecraft que l'on peut conserver et montrer. »
- **Sortie :** `SANCTUARY / CANON 088`.
- **Source :** [Canon et styles de pellicule](../../README.md#red-stoner-et-photographie).
## 089 — Un coffre-fort est une coque creuse de 26 blocs
- **Format / durée :** construction, 15 s.
- **Accroche :** « Ni un mur. Ni un cube plein. »
- **Plans :** structure incorrecte refusée ; coque 3 × 3 × 3 avec centre libre ; activation des textures sur les six faces.
- **Texte / voix :** « Les 26 Safe Blocks doivent appartenir au même joueur. Le centre peut cacher n'importe quel bloc. »
- **Sortie :** `SANCTUARY / COFFRE-FORT 089`.
- **Source :** [coque du coffre-fort, alpha.205](../../CHANGELOG.md#2620-alpha205).
## 090 — Pour braquer un coffre, il faut déjà en posséder un
- **Format / durée :** règle de braquage, 18 s.
- **Accroche :** « Un voleur doit mettre sa propre banque en jeu. »
- **Plans :** forage refusé sans coffre personnel ; construction ou preuve du coffre ; forage continu de 60 secondes et transfert final.
- **Texte / voix :** « Le propriétaire doit être connecté. Une réussite vole 20 %, consomme la foreuse, alerte la victime et protège le coffre pendant 24 heures. »
- **Sortie :** `SANCTUARY / BRAQUAGE 090`.
- **Source :** [forage et transfert de l'alpha.202](../../CHANGELOG.md#2620-alpha202) ; [condition de propriété de l'alpha.225](../../CHANGELOG.md#2620-alpha225).
## 091 — Le Creeperlock explose sans casser le monde
- **Format / durée :** arsenal, 12 s.
- **Accroche :** « Une explosion de combat, pas une explosion de terrain. »
- **Plans :** tir sur plusieurs cibles ; souffle et recul ; même décor intact après l'impact.
- **Texte / voix :** « Le Creeperlock consomme une poudre à canon et inflige des dégâts selon la distance, sans détruire de bloc. »
- **Sortie :** `SANCTUARY / OUCH 091`.
- **Source :** [Creeperlock](../../ouch/README.md#arsenal).
## 092 — La Mitraillette tire les métaux de ton inventaire
- **Format / durée :** arsenal, 14 s.
- **Accroche :** « La munition décide des dégâts. »
- **Plans :** pépites cuivre, fer, or, argent et titane alignées ; tir continu ; pépite tenue en main secondaire choisie en priorité.
- **Texte / voix :** « Cinq métaux, cinq puissances. Un tir toutes les cinq ticks jusqu'à 18 blocs. »
- **Sortie :** `SANCTUARY / OUCH 092`.
- **Source :** [Mitraillette](../../ouch/README.md#arsenal).
## 093 — L'Expulseur transforme ton inventaire en projectiles
- **Format / durée :** arsenal absurde, 13 s.
- **Accroche :** « Ce qui se trouve dans ton sac peut devenir une arme. »
- **Plans :** inventaire principal contenant les munitions improvisées ; jusqu'à huit objets projetés ; hotbar montrée intacte.
- **Texte / voix :** « Les objets restent physiques et récupérables. Les neuf cases de la barre d'action ne sont jamais consommées. »
- **Sortie :** `SANCTUARY / OUCH 093`.
- **Source :** [Expulseur](../../ouch/README.md#arsenal).
## 094 — L'Aspirateur retire aussi le bloc visé
- **Format / durée :** arsenal utilitaire, 13 s.
- **Accroche :** « Attirer les objets n'était que la moitié de la fonction. »
- **Plans :** objets au sol attirés dans un rayon de dix blocs ; visée d'un bloc cassable ; extraction et récupération.
- **Texte / voix :** « Il refuse les fluides, les block entities et toute interaction sans permission. »
- **Sortie :** `SANCTUARY / OUCH 094`.
- **Source :** [Aspirateur](../../ouch/README.md#arsenal).
## 095 — Chaque minerai brut change le Mining Rifle
- **Format / durée :** arsenal, 17 s.
- **Accroche :** « Cuivre, fer, or, argent ou titane : le fusil ne tire pas la même chose. »
- **Plans :** cinq munitions ; rechargement plus rapide avec l'or ; bonus argent sur un mort-vivant ; titane traversant une seconde cible.
- **Texte / voix :** « L'argent ajoute des dégâts aux morts-vivants. Le titane conserve 75 % des dégâts sur une seconde cible. »
- **Sortie :** `SANCTUARY / OUCH 095`.
- **Source :** [Mining Rifle](../../ouch/README.md#arsenal).
## 096 — L'Arc bêta tire quatre flèches par seconde
- **Format / durée :** arsenal historique, 13 s.
- **Accroche :** « Avant l'animation de charge, l'arc tirait immédiatement. »
- **Plans :** premier clic instantané ; maintien produisant quatre tirs par seconde ; relâchement sans projectile supplémentaire.
- **Texte / voix :** « Chaque tir consomme une vraie flèche vanilla et conserve ses effets compatibles. »
- **Sortie :** `SANCTUARY / OUCH 096`.
- **Source :** [Arc bêta](../../ouch/README.md#arsenal).
## 097 — Le Shop commence avec trois offres
- **Format / durée :** progression économique, 14 s.
- **Accroche :** « Le magasin n'est pas entièrement ouvert au début. »
- **Plans :** trois cartes actives et six verrouillées ; achat d'un emplacement ; grille progressivement étendue jusqu'à neuf offres.
- **Texte / voix :** « Les six emplacements supplémentaires coûtent 2, 4, 8, 12, 18 puis 25 niveaux. »
- **Sortie :** `SANCTUARY / SHOP 097`.
- **Source :** [progression du Shop, alpha.193](../../CHANGELOG.md#2620-alpha193).
## 098 — Le marché noir est construit avec de vrais panneaux
- **Format / durée :** interaction sociale, 18 s.
- **Accroche :** « Ici, une annonce existe parce qu'un panneau existe dans le monde. »
- **Plans :** vendeur tenant un objet et cliquant sur un panneau ; prix et pseudonyme visibles ; achat par un second joueur puis livraison Mailbox.
- **Texte / voix :** « L'objet, le paiement et les composants sont séquestrés et livrés par le serveur. Casser le panneau obéit à son propriétaire. »
- **Sortie :** `SANCTUARY / MARCHÉ 098`.
- **Source :** [Black Market physique](../../sanctuary/BLACK_MARKET.md#utilisation).
## 099 — Les joueurs peuvent écrire leurs propres quêtes
- **Format / durée :** interaction sociale, 18 s.
- **Accroche :** « KILL. BRING. PAY. Une quête écrite par un autre joueur. »
- **Plans :** création sur le panneau rose ; lecture et confirmation par un second joueur ; complétion puis arrivée de la récompense dans la Mailbox.
- **Texte / voix :** « La récompense est déposée dès la publication. Le serveur suit l'objectif et ne la livre qu'une fois. »
- **Sortie :** `SANCTUARY / QUÊTES 099`.
- **Source :** [demandes communautaires, alpha.201](../../CHANGELOG.md#2620-alpha201).
## 100 — Appuie sur J
- **Format / durée :** absurdité sèche, 9 s.
- **Accroche :** plan parfaitement sérieux du joueur, immobile au centre d'une pièce blanche.
- **Plans :** pression sur `J` ; action de six secondes ; boule créée, puis transformation de neuf boules en cube dans une coupe séparée.
- **Texte / voix :** « Sanctuary réinterprète aussi les mauvaises idées. »
- **Sortie :** `SANCTUARY / ONLY FUN 100`.
- **Source :** [action et cube](../../onlyfun/ABJECTION.md#abj-01--actions-serveur) ; [raccourci configurable](../../onlyfun/ABJECTION.md#abj-03--raccourci-configurable).
## Ordre de production recommandé
Ne pas tourner les vidéos dans leur ordre de publication. Les regrouper par décor réduit fortement le temps de capture :
1. **Laboratoire interface :** 003 à 019, 083 à 086 et 097.
2. **Archipel et worldgen :** 002, 020 à 027, 053 et 072 à 079.
3. **Alpha / maison Indev :** 028 à 031.
4. **Terrain de déplacement :** 032 à 039.
5. **Bestiaire contrôlé :** 040 à 059.
6. **Ferme et cuisine :** 060 à 071.
7. **Atelier Red-Stoner / Another World :** 080 à 090.
8. **Stand de tir Ouch :** 091 à 096.
9. **Place de serveur :** 098 à 100.
## Premier lot public conseillé
Avant de produire les cent vidéos, valider la grammaire avec douze épisodes très différents : **001, 003, 006, 014, 020, 031, 041, 053, 062, 077, 086 et 099**. Ils couvrent le manifeste, l'interface, une mécanique spectaculaire, l'exploration, une créature, l'agriculture, l'ambiance et le multijoueur sans nécessiter cent tournages pour apprendre ce qui fonctionne.
+5 -5
View File
@@ -6,7 +6,7 @@ org.gradle.configuration-cache=false
minecraft_version=26.2
loader_version=0.19.3
loom_version=1.17-SNAPSHOT
fabric_api_version=0.159.0+26.2
fabric_api_version=0.158.0+26.2
# Sanctuary 26.2
# Standby modules stay at zero until their migration really begins.
@@ -14,9 +14,9 @@ mod_version=0.0.0-alpha.0
module_lifecycle=standby
ambiance_version=0.0.0-alpha.22
ambiance_lifecycle=active
redstoner_version=0.0.0-alpha.18
redstoner_version=0.0.0-alpha.13
redstoner_lifecycle=active
anotherworld_version=0.0.0-alpha.57
anotherworld_version=0.0.0-alpha.56
anotherworld_lifecycle=active
itsalive_version=0.0.0-alpha.38
itsalive_lifecycle=active
@@ -28,7 +28,7 @@ iliketomoveit_version=0.0.0-alpha.18
iliketomoveit_lifecycle=active
onlyfun_version=0.0.0-alpha.27
onlyfun_lifecycle=active
sanctuary_version=0.0.0-alpha.137
sanctuary_version=0.0.0-alpha.130
sanctuary_lifecycle=active
pack_version=26.2.0-alpha.230
pack_version=26.2.0-alpha.226
maven_group=fr.koka99cab.sanctuary26
@@ -1,60 +0,0 @@
{
"schema_version": 1,
"status": "released",
"source": {
"pack_version": "26.2.0-alpha.226",
"modules": {
"anotherworld": "0.0.0-alpha.56",
"sanctuary": "0.0.0-alpha.130"
}
},
"target": {
"pack_version": "26.2.0-alpha.227",
"modules": {
"anotherworld": "0.0.0-alpha.57",
"sanctuary": "0.0.0-alpha.131"
}
},
"backroom": {
"owner": "sanctuary",
"dimension": "sanctuary:backroom",
"entry_requires_sneaking_at_bed": true,
"exit_requires_bed": true,
"arrival_radius_blocks": 2000,
"entry_inventory_to_mailbox": true,
"entry_transfer_atomic": true,
"entry_transfer_idempotent": true,
"lost_items_persisted": true,
"lost_item_registry_bounded": true,
"renewable_chests": true,
"personal_losses_prioritized": true,
"shared_old_losses_supported": true,
"anotherworld_material_api": "BackroomMaterialApi"
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"sanctuary_source_data_version": 13,
"sanctuary_target_data_version": 14,
"sanctuary_network_protocol": 24,
"network_protocol_changed": false,
"client_server_update_required": true
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true,
"fabric_api": {
"source": "0.158.0+26.2",
"target": "0.159.0+26.2",
"modrinth_version_id": "BgeCGgGZ"
},
"jei": {
"source": "30.29.0.198",
"target": "30.29.0.199",
"modrinth_version_id": "astSvyl6"
}
}
}
@@ -1,58 +0,0 @@
{
"schema_version": 1,
"status": "released",
"source": {
"pack_version": "26.2.0-alpha.227",
"modules": {
"redstoner": "0.0.0-alpha.13",
"sanctuary": "0.0.0-alpha.131"
}
},
"target": {
"pack_version": "26.2.0-alpha.228",
"modules": {
"redstoner": "0.0.0-alpha.15",
"sanctuary": "0.0.0-alpha.132"
}
},
"backroom": {
"level_count": 5,
"minimum_y": -64,
"maximum_y_exclusive": 384,
"entry_floor_y": 367,
"monsters_disabled": true,
"arrival_torch": true,
"matching_vertical_transitions": true,
"connected_maze": true,
"dense_city_and_highways": true,
"three_dimensional_brick_grid": true,
"intentional_void_holes": true
},
"storage_terminal": {
"click_to_focus": true,
"inventory_key_closes": true,
"shulker_contents_searchable": true,
"shulker_remains_result": true,
"vanilla_shulker_preview_preserved": true
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"persistent_data_formats_changed": false,
"network_protocols_changed": false,
"redstoner_data_version": 3,
"redstoner_network_protocol": 7,
"sanctuary_data_version": 14,
"sanctuary_network_protocol": 24,
"client_server_update_required": true,
"existing_backroom_chunks_preserved": true,
"new_backroom_generation_applies_to_new_chunks": true
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true
}
}
@@ -1,45 +0,0 @@
{
"schema_version": 1,
"status": "released",
"source": {
"pack_version": "26.2.0-alpha.228",
"modules": {
"redstoner": "0.0.0-alpha.15"
}
},
"target": {
"pack_version": "26.2.0-alpha.229",
"modules": {
"redstoner": "0.0.0-alpha.16"
}
},
"storage_terminal_sorting": {
"owner": "redstoner",
"modes": [
"alphabetical",
"count_ascending",
"count_descending"
],
"server_authoritative": true,
"explicit_user_action": true,
"returns_to_first_page": true,
"periodic_refresh_preserves_order": true,
"periodic_refresh_preserves_hovered_stack": true
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"persistent_data_formats_changed": false,
"network_protocols_changed": false,
"redstoner_data_version": 3,
"redstoner_network_protocol": 7,
"client_server_update_required": true
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true
}
}
@@ -1,44 +0,0 @@
{
"schema_version": 1,
"status": "released",
"source": {
"pack_version": "26.2.0-alpha.229",
"modules": {
"sanctuary": "0.0.0-alpha.132",
"redstoner": "0.0.0-alpha.16"
}
},
"target": {
"pack_version": "26.2.0-alpha.230",
"modules": {
"sanctuary": "0.0.0-alpha.136",
"redstoner": "0.0.0-alpha.17"
}
},
"sanctuary_polish": {
"quest_completion_attribution": true,
"backroom_exit_beds_on_every_level": true,
"persistent_public_rankings": true,
"redstoner_trophy_panel": true,
"white_cape_shield_pattern_projection": true,
"adult_chicken_wolf_armor": true
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"persistent_data_formats_changed": true,
"network_protocols_changed": false,
"sanctuary_data_version": 15,
"sanctuary_network_protocol": 24,
"redstoner_data_version": 4,
"redstoner_network_protocol": 7,
"client_server_update_required": true
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true
}
}
@@ -1,38 +0,0 @@
{
"schema_version": 1,
"status": "released",
"module": "sanctuary",
"source_module_version": "0.0.0-alpha.130",
"target_module_version": "0.0.0-alpha.131",
"integration_modules": {
"anotherworld": {
"source": "0.0.0-alpha.56",
"target": "0.0.0-alpha.57",
"data_version_changed": false,
"network_protocol_changed": false
}
},
"source_data_version": 13,
"target_data_version": 14,
"migration": {
"kind": "monotone_addition",
"save_key": "sanctuary:backroom",
"existing_player_progress_preserved": true,
"existing_shop_data_preserved": true,
"existing_mailboxes_preserved": true,
"mailbox_transaction_history_default": [],
"backroom_loss_registry_default": [],
"backroom_chest_registry_default": []
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"network_protocol_changed": false,
"registry_ids_added": [
"sanctuary:backroom",
"sanctuary:backroom_loss_id",
"sanctuary:backroom_architecture"
]
}
}
@@ -1,36 +0,0 @@
{
"schema_version": 1,
"status": "released",
"modules": {
"sanctuary": {
"source_module_version": "0.0.0-alpha.134",
"target_module_version": "0.0.0-alpha.135",
"source_data_version": 14,
"target_data_version": 15
},
"redstoner": {
"source_module_version": "0.0.0-alpha.16",
"target_module_version": "0.0.0-alpha.17",
"source_data_version": 3,
"target_data_version": 4
}
},
"migration": {
"kind": "monotone_addition",
"ranking_save_key": "sanctuary:rankings",
"vanilla_statistics_imported_once": true,
"player_identity": "uuid",
"ranking_metric_index_version": 1,
"trophy_panel_id": "redstoner:trophy_panel",
"trophy_panel_data_version": 1,
"existing_redstoner_block_entities_unchanged": true
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"network_protocols_changed": false,
"newer_ranking_data_is_read_only": true,
"newer_panel_data_is_read_only": true
}
}
+15 -15
View File
@@ -14,24 +14,24 @@ hash = "41f4f4c88c71d0a23446d5014f7407d55ad477441847099cad24492b032335a0"
[[files]]
file = "mods/ambiance-0.0.0-alpha.22.jar"
hash = "cdc60ed8f15ca910f41954d9acb6a74ac103e85c73f665fd1bc67be7eab1f15a"
hash = "0e862db1c5efb5c0745b82f991d5480f4305822c3d3f53ed38e2d2d47efabee1"
[[files]]
file = "mods/anotherworld-0.0.0-alpha.57.jar"
hash = "634a122b406bd7206fd19947687fd7cb37e4cc4098822eecf3f0b7d1fea79d90"
file = "mods/anotherworld-0.0.0-alpha.56.jar"
hash = "cec8ed493f3c86e4c4481081be110382407f7a5c631b7cf27dd80f80c6f2e92a"
[[files]]
file = "mods/events-0.0.0-alpha.0.jar"
hash = "09bd8dfccca004de46ad3237728ef532de50d04c31b88f1e33721204d3c70cd8"
hash = "16bf9fc194988086f51ba6987546b6aa5bc634fe8fcb118b88540ed16991049c"
[[files]]
file = "mods/fabric-api.pw.toml"
hash = "b8ea5f403a0cdfbfd5ab1704bb61278e8dcf31b525db9c242b22047852d34f85"
hash = "df032a6b051d0262cf24945c43e0e6b915e8cc1d62af62fe933932e7f35960c4"
metafile = true
[[files]]
file = "mods/iliketomoveit-0.0.0-alpha.18.jar"
hash = "cd026c4735e945ec6cc19261165033dc10bcbbba75af412f4145e54437a61854"
hash = "94044341de08c9b89e0c1e6af6857da64b472fadbc94051ab76fc3cf1a0f0006"
[[files]]
file = "mods/iris.pw.toml"
@@ -40,32 +40,32 @@ metafile = true
[[files]]
file = "mods/itsalive-0.0.0-alpha.38.jar"
hash = "d905a05edbcb841b173ef5b0090fe7a117ec06e119fba7e184338fec7671fde6"
hash = "a42885703439646687ac281b1c124d1cddb3ca0595a41d930733b94bd8f1aa2f"
[[files]]
file = "mods/jei.pw.toml"
hash = "9cc4a91f57effc48b94f6acc1f43067bb8eb99fd1df3397612c55761de9ec2c2"
hash = "cffb13bae33cd8df605c62e7fa66089428dc684e0ab973f662a8a710c45c306d"
metafile = true
[[files]]
file = "mods/masterkey-0.0.0-alpha.9.jar"
hash = "cef973db96c7523fe828a2ca57ce1283ed1da3b1ae3c646a88ea601ecfe4e825"
hash = "979c14e0a527be63688e97af8349be07b027d3149107c3e9e728a2b1e1ec65ff"
[[files]]
file = "mods/onlyfun-0.0.0-alpha.27.jar"
hash = "1ff5b8853c5a813bbb6c6cf5787c8311d8e72760ed0e9c2adb1da897241e4e1e"
hash = "023ba80a8f3326a6b534b343753d5630d3be0700d25613ea21057cbfe44fdc73"
[[files]]
file = "mods/ouch-0.0.0-alpha.11.jar"
hash = "fd609cfe5f96ae1d2b41b2da7faf077047514772b56bbe130251db3bf72c2ac8"
hash = "2c15c9d0adeb315b2c3a6796f27c9dc62dd33365f35ea584552d97500b833eb8"
[[files]]
file = "mods/redstoner-0.0.0-alpha.17.jar"
hash = "d7bef2bdf533d2c83f7b05549c01a77028dd5edc60418c7244447ca61160f216"
file = "mods/redstoner-0.0.0-alpha.13.jar"
hash = "753835558c1037e39ca669737190013bd59a3ab16f7d5156c52e940c54ff9c60"
[[files]]
file = "mods/sanctuary-0.0.0-alpha.136.jar"
hash = "973c12a3f26eb516bc1a053ebd18dab3f845645807448ff5638a1e9b26ba28bd"
file = "mods/sanctuary-0.0.0-alpha.130.jar"
hash = "8906be26de369903a0d5152d80a95a1c47b3468e4afca17ab4683b8300ad69a9"
[[files]]
file = "mods/simple-voice-chat.pw.toml"
+4 -4
View File
@@ -1,14 +1,14 @@
name = "Fabric API"
filename = "fabric-api-0.159.0+26.2.jar"
filename = "fabric-api-0.158.0+26.2.jar"
side = "both"
pin = true
[download]
url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/BgeCGgGZ/fabric-api-0.159.0%2B26.2.jar"
url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/NqwNSxwA/fabric-api-0.158.0%2B26.2.jar"
hash-format = "sha512"
hash = "4d5d9c3daafe19be187f638f2d6de3db26420b8720ef5a266cfa81f79d9019cab8c86e6d47478bbb0b0ee0b8f769204b72c843caa2e4aabd94adc41f3dafb518"
hash = "4c2c1ebe74ffd54875a01ff371b53ba3d8674ac98d561f7dae02a96d3d37fbdbc5f5abc6e820f73b6154d6f873ddd05a442b0998ed2d456863dc0ad972e040a6"
[update]
[update.modrinth]
mod-id = "P7dR8mSH"
version = "BgeCGgGZ"
version = "NqwNSxwA"
+4 -4
View File
@@ -1,14 +1,14 @@
name = "Just Enough Items (JEI)"
filename = "jei-26.2-fabric-30.29.0.199.jar"
filename = "jei-26.2-fabric-30.29.0.198.jar"
side = "both"
pin = true
[download]
url = "https://cdn.modrinth.com/data/u6dRKJwZ/versions/astSvyl6/jei-26.2-fabric-30.29.0.199.jar"
url = "https://cdn.modrinth.com/data/u6dRKJwZ/versions/67Dvn8sL/jei-26.2-fabric-30.29.0.198.jar"
hash-format = "sha512"
hash = "2849ff6a4cc91822798f0c70e7749c298b911c1037fc74e9ab0881734b8d5cb6507fb3169b4a240ef6c37eb6f7c573794c46073ff352f160feee245233b2abe5"
hash = "06131c9528549840e07ddc9634e23454cd454380d7749a23d818f0efe0dacc02874e967ceb74a24ee82efb13e0fe2df4026f988a68f54f890586967e91dcaa5b"
[update]
[update.modrinth]
mod-id = "u6dRKJwZ"
version = "astSvyl6"
version = "67Dvn8sL"
+2 -2
View File
@@ -1,12 +1,12 @@
name = "Sanctuary 26.2"
author = "KOKA99CAB"
version = "26.2.0-alpha.230"
version = "26.2.0-alpha.226"
pack-format = "packwiz:1.1.0"
[index]
file = "index.toml"
hash-format = "sha256"
hash = "a78df91699f1ffa661e1bf59101891a217bd74334ee338b96824f5a9b745e443"
hash = "d3b929a596081adc3960866a2f939d4155d1b67ecb22bfe6050931a7d1d0f0a2"
[versions]
fabric = "0.19.3"
+11 -11
View File
@@ -3,37 +3,37 @@
"game": "minecraft",
"name": "Sanctuary 26.2",
"summary": "Sanctuary 26.2 — progression, exploration, worldgen and multiplayer systems by KOKA99CAB.",
"versionId": "26.2.0-alpha.230",
"versionId": "26.2.0-alpha.226",
"files": [
{
"path": "mods/fabric-api-0.159.0+26.2.jar",
"path": "mods/fabric-api-0.158.0+26.2.jar",
"hashes": {
"sha1": "5307b87f8336f27e8f963263ac514f4bc6175d90",
"sha512": "4d5d9c3daafe19be187f638f2d6de3db26420b8720ef5a266cfa81f79d9019cab8c86e6d47478bbb0b0ee0b8f769204b72c843caa2e4aabd94adc41f3dafb518"
"sha1": "7d0fb560579d28b933bdbc36b47449efe78d3bda",
"sha512": "4c2c1ebe74ffd54875a01ff371b53ba3d8674ac98d561f7dae02a96d3d37fbdbc5f5abc6e820f73b6154d6f873ddd05a442b0998ed2d456863dc0ad972e040a6"
},
"env": {
"client": "required",
"server": "required"
},
"downloads": [
"https://cdn.modrinth.com/data/P7dR8mSH/versions/BgeCGgGZ/fabric-api-0.159.0%2B26.2.jar"
"https://cdn.modrinth.com/data/P7dR8mSH/versions/NqwNSxwA/fabric-api-0.158.0%2B26.2.jar"
],
"fileSize": 2535793
"fileSize": 2535765
},
{
"path": "mods/jei-26.2-fabric-30.29.0.199.jar",
"path": "mods/jei-26.2-fabric-30.29.0.198.jar",
"hashes": {
"sha1": "1d6ab9269be16f85fb13a34a36b34ad07a9b32a7",
"sha512": "2849ff6a4cc91822798f0c70e7749c298b911c1037fc74e9ab0881734b8d5cb6507fb3169b4a240ef6c37eb6f7c573794c46073ff352f160feee245233b2abe5"
"sha1": "652e964c71a699fdfdd96cda265ea703b13c6ef4",
"sha512": "06131c9528549840e07ddc9634e23454cd454380d7749a23d818f0efe0dacc02874e967ceb74a24ee82efb13e0fe2df4026f988a68f54f890586967e91dcaa5b"
},
"env": {
"client": "required",
"server": "required"
},
"downloads": [
"https://cdn.modrinth.com/data/u6dRKJwZ/versions/astSvyl6/jei-26.2-fabric-30.29.0.199.jar"
"https://cdn.modrinth.com/data/u6dRKJwZ/versions/67Dvn8sL/jei-26.2-fabric-30.29.0.198.jar"
],
"fileSize": 1849188
"fileSize": 1839279
},
{
"path": "mods/voicechat-fabric-2.6.22+26.2.jar",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"pack_version": "26.2.0-alpha.230",
"pack_version": "26.2.0-alpha.226",
"release_channel": "alpha",
"minecraft_version": "26.2",
"fabric_loader_version": "0.19.3",
@@ -9,7 +9,7 @@
"project_id": "FgOqyAXd",
"project_slug": "sanctuary-koka99",
"project_url": "https://modrinth.com/modpack/sanctuary-koka99",
"primary_file": "sanctuary-26.2.0-alpha.230.mrpack",
"primary_file": "sanctuary-26.2.0-alpha.226.mrpack",
"shared_client_server_pack": true,
"update_owner": "packwiz-installer",
"check_on_launch": true,
@@ -13,7 +13,7 @@ import org.slf4j.LoggerFactory;
public final class RedStonerMod implements ModInitializer {
public static final String MOD_ID = "redstoner";
public static final String DISPLAY_NAME = "Red-Stoner";
public static final int CURRENT_DATA_VERSION = 4;
public static final int CURRENT_DATA_VERSION = 3;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public RedStonerMod() {
@@ -29,7 +29,7 @@ public final class RedStonerMod implements ModInitializer {
RedStonerRegistries.initialize();
RedStonerMenus.initialize();
RedStonerNetworking.initialize();
LOGGER.info("[{}] Computers, Drawer, Particuleur, Storage Terminal and Trophy Panel ready (data v{}, network v{}).",
LOGGER.info("[{}] Computers, Drawer, Particuleur and Storage Terminal ready (data v{}, network v{}).",
DISPLAY_NAME, CURRENT_DATA_VERSION, RedStonerNetworking.PROTOCOL_VERSION);
}
}
@@ -1,176 +0,0 @@
package fr.koka99cab.sanctuary26.redstoner.block;
import com.mojang.serialization.MapCodec;
import fr.koka99cab.sanctuary26.redstoner.block.entity.TrophyPanelBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import java.util.Map;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
import net.minecraft.util.RandomSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.ScheduledTickAccess;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.Mirror;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityTicker;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.VoxelShape;
/** Wall-mounted, server-authoritative trophy and ranking panel. */
public final class TrophyPanelBlock extends BaseEntityBlock {
public static final MapCodec<TrophyPanelBlock> CODEC = simpleCodec(TrophyPanelBlock::new);
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
private static final Map<Direction, VoxelShape> SHAPES = Map.of(
Direction.NORTH, Block.box(0.0D, 0.0D, 14.0D, 16.0D, 16.0D, 16.0D),
Direction.SOUTH, Block.box(0.0D, 0.0D, 0.0D, 16.0D, 16.0D, 2.0D),
Direction.WEST, Block.box(14.0D, 0.0D, 0.0D, 16.0D, 16.0D, 16.0D),
Direction.EAST, Block.box(0.0D, 0.0D, 0.0D, 2.0D, 16.0D, 16.0D)
);
public TrophyPanelBlock(BlockBehaviour.Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
}
@Override
public MapCodec<TrophyPanelBlock> codec() {
return CODEC;
}
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new TrophyPanelBlockEntity(pos, state);
}
@Override
public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state,
BlockEntityType<T> type) {
if (level.isClientSide()) return null;
return createTickerHelper(type, RedStonerRegistries.TROPHY_PANEL_BLOCK_ENTITY,
TrophyPanelBlockEntity::serverTick);
}
@Override
protected RenderShape getRenderShape(BlockState state) {
return RenderShape.MODEL;
}
@Override
public BlockState getStateForPlacement(BlockPlaceContext context) {
Direction facing = context.getClickedFace();
if (!facing.getAxis().isHorizontal()) return null;
BlockState state = defaultBlockState().setValue(FACING, facing);
return state.canSurvive(context.getLevel(), context.getClickedPos()) ? state : null;
}
@Override
protected boolean canSurvive(BlockState state, LevelReader level, BlockPos pos) {
Direction facing = state.getValue(FACING);
BlockPos support = pos.relative(facing.getOpposite());
return level.getBlockState(support).isFaceSturdy(level, support, facing);
}
@Override
protected BlockState updateShape(BlockState state, LevelReader level, ScheduledTickAccess ticks,
BlockPos pos, Direction direction, BlockPos neighborPos, BlockState neighborState, RandomSource random) {
return direction == state.getValue(FACING).getOpposite() && !state.canSurvive(level, pos)
? Blocks.AIR.defaultBlockState()
: super.updateShape(state, level, ticks, pos, direction, neighborPos, neighborState, random);
}
@Override
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) {
return SHAPES.get(state.getValue(FACING));
}
@Override
public void setPlacedBy(Level level, BlockPos pos, BlockState state, LivingEntity placer, ItemStack stack) {
super.setPlacedBy(level, pos, state, placer, stack);
if (placer instanceof ServerPlayer player
&& level.getBlockEntity(pos) instanceof TrophyPanelBlockEntity panel) panel.claim(player);
}
@Override
protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos,
Player player, InteractionHand hand, BlockHitResult hit) {
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (!(player instanceof ServerPlayer serverPlayer)
|| !(level.getBlockEntity(pos) instanceof TrophyPanelBlockEntity panel)) return InteractionResult.CONSUME;
if (player.isSecondaryUseActive()) return cycle(panel, serverPlayer, true);
boolean operator = serverPlayer.createCommandSourceStack().permissions()
.hasPermission(Permissions.COMMANDS_GAMEMASTER);
if (!panel.canConfigure(serverPlayer, operator)) {
return cycle(panel, serverPlayer, false);
}
TrophyPanelBlockEntity.ConfigureResult result = panel.configure(serverPlayer, stack);
if (result == TrophyPanelBlockEntity.ConfigureResult.NO_METRIC) {
return cycle(panel, serverPlayer, false);
}
String key = switch (result) {
case SUCCESS -> "configured";
case NO_METRIC -> throw new IllegalStateException("Handled above");
case NEWER_DATA -> "newer_data";
};
serverPlayer.sendSystemMessage(Component.translatable("message.redstoner.trophy_panel." + key));
return InteractionResult.SUCCESS_SERVER;
}
@Override
protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (!(player instanceof ServerPlayer serverPlayer)
|| !(level.getBlockEntity(pos) instanceof TrophyPanelBlockEntity panel)) return InteractionResult.CONSUME;
return cycle(panel, serverPlayer, player.isSecondaryUseActive());
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(FACING);
}
@Override
protected BlockState rotate(BlockState state, Rotation rotation) {
return state.setValue(FACING, rotation.rotate(state.getValue(FACING)));
}
@Override
protected BlockState mirror(BlockState state, Mirror mirror) {
return state.rotate(mirror.getRotation(state.getValue(FACING)));
}
private static InteractionResult cycle(TrophyPanelBlockEntity panel, ServerPlayer player, boolean backwards) {
boolean changed = backwards ? panel.previousPage() : panel.nextPage();
if (!changed) {
player.sendSystemMessage(Component.translatable("message.redstoner.trophy_panel.newer_data"));
return InteractionResult.CONSUME;
}
player.sendSystemMessage(Component.translatable("message.redstoner.trophy_panel.page",
panel.pageNumber(), panel.pageCount()));
return InteractionResult.SUCCESS_SERVER;
}
}
@@ -1,282 +0,0 @@
package fr.koka99cab.sanctuary26.redstoner.block.entity;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka99cab.sanctuary26.redstoner.block.TrophyPanelBlock;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyPanelRules;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingMetric;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingSnapshot;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingUnit;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.SanctuaryRankingApi;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.UUIDUtil;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.ItemOwner;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.world.phys.Vec3;
/** Persistent panel configuration plus a transient, bounded client-visible ranking snapshot. */
public final class TrophyPanelBlockEntity extends BlockEntity implements ItemOwner {
private static final int DATA_VERSION = 1;
private static final int REFRESH_INTERVAL_TICKS = 20;
private static final String CLIENT_SNAPSHOT_TAG = "VisibleSnapshot";
private static final Codec<Page> PAGE_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("metric").forGetter(Page::metricId),
ItemStack.OPTIONAL_CODEC.fieldOf("icon").forGetter(Page::icon)
).apply(instance, Page::new));
private static final Codec<ClientEntry> CLIENT_ENTRY_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("name").forGetter(ClientEntry::name),
Codec.LONG.fieldOf("value").forGetter(ClientEntry::value),
Codec.INT.fieldOf("rank").forGetter(ClientEntry::rank)
).apply(instance, ClientEntry::new));
private static final Codec<ClientSnapshot> CLIENT_SNAPSHOT_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("metric").forGetter(ClientSnapshot::metricId),
Codec.STRING.fieldOf("translation_key").forGetter(ClientSnapshot::translationKey),
Codec.STRING.fieldOf("unit").forGetter(ClientSnapshot::unitKey),
CLIENT_ENTRY_CODEC.listOf(0, RankingSnapshot.MAX_PODIUM_SIZE).fieldOf("podium")
.forGetter(ClientSnapshot::podium),
Codec.LONG.fieldOf("owner_value").forGetter(ClientSnapshot::ownerValue),
Codec.INT.fieldOf("owner_rank").forGetter(ClientSnapshot::ownerRank)
).apply(instance, ClientSnapshot::new));
private int dataVersion = DATA_VERSION;
private UUID owner;
private String ownerName = "?";
private List<Page> pages = List.of(defaultPage());
private int currentPage;
private long lastRefreshTick = Long.MIN_VALUE;
private ClientSnapshot visibleSnapshot = ClientSnapshot.empty(SanctuaryRankingApi.defaultMetric());
public TrophyPanelBlockEntity(BlockPos pos, BlockState state) {
super(RedStonerRegistries.TROPHY_PANEL_BLOCK_ENTITY, pos, state);
}
public static void serverTick(net.minecraft.world.level.Level level, BlockPos pos, BlockState state,
TrophyPanelBlockEntity panel) {
if (!(level instanceof ServerLevel serverLevel)) return;
long now = level.getGameTime();
if (panel.lastRefreshTick != Long.MIN_VALUE && now - panel.lastRefreshTick < REFRESH_INTERVAL_TICKS) return;
panel.lastRefreshTick = now;
panel.refresh(serverLevel);
}
public void claim(ServerPlayer player) {
if (!writable() || owner != null) return;
owner = player.getUUID();
ownerName = cleanName(player.getGameProfile().name());
syncPersistent();
}
public boolean canConfigure(ServerPlayer player, boolean operator) {
return writable() && TrophyPanelRules.canConfigure(owner, player.getUUID(), operator);
}
public ConfigureResult configure(ServerPlayer player, ItemStack stack) {
if (!writable()) return ConfigureResult.NEWER_DATA;
List<RankingMetric> offered = SanctuaryRankingApi.itemMetrics(stack);
if (offered.isEmpty()) return ConfigureResult.NO_METRIC;
List<Identifier> currentIds = pages.stream().map(Page::metricId).toList();
List<Identifier> mergedIds = TrophyPanelRules.mergeMetricIds(currentIds,
offered.stream().map(RankingMetric::id).toList());
List<Page> next = new ArrayList<>();
for (Identifier id : mergedIds) {
Page existing = pages.stream().filter(page -> page.metricId().equals(id)).findFirst().orElse(null);
next.add(existing == null ? new Page(id, stack.copyWithCount(1)) : existing);
}
pages = List.copyOf(next);
currentPage = Math.max(0, pages.indexOf(pages.stream()
.filter(page -> offered.stream().anyMatch(metric -> metric.id().equals(page.metricId())))
.findFirst().orElse(pages.getFirst())));
ownerName = cleanName(player.getGameProfile().name());
syncPersistent();
if (level instanceof ServerLevel serverLevel) refresh(serverLevel);
return ConfigureResult.SUCCESS;
}
public boolean nextPage() {
return movePage(1);
}
public boolean previousPage() {
return movePage(-1);
}
private boolean movePage(int offset) {
if (!writable() || pages.isEmpty()) return false;
currentPage = TrophyPanelRules.pageByOffset(currentPage, pages.size(), offset);
syncPersistent();
if (level instanceof ServerLevel serverLevel) refresh(serverLevel);
return true;
}
public Page currentPage() {
return pages.get(Math.clamp(currentPage, 0, pages.size() - 1));
}
public int pageNumber() {
return currentPage + 1;
}
public int pageCount() {
return pages.size();
}
public String ownerName() {
return ownerName;
}
public ClientSnapshot visibleSnapshot() {
return visibleSnapshot;
}
private void refresh(ServerLevel level) {
Page page = currentPage();
RankingSnapshot snapshot = SanctuaryRankingApi.snapshot(level.getServer(), page.metricId(), owner);
ClientSnapshot next = ClientSnapshot.from(snapshot);
if (next.equals(visibleSnapshot)) return;
visibleSnapshot = next;
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), Block.UPDATE_CLIENTS);
}
@Override
protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
output.putInt("DataVersion", dataVersion);
if (owner != null) output.store("Owner", UUIDUtil.CODEC, owner);
output.putString("OwnerName", ownerName);
output.store("Pages", PAGE_CODEC.listOf(0, TrophyPanelRules.MAX_PAGES), pages);
output.putInt("CurrentPage", currentPage);
}
@Override
protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
dataVersion = Math.max(DATA_VERSION, input.getIntOr("DataVersion", 0));
owner = input.read("Owner", UUIDUtil.CODEC).orElse(null);
ownerName = cleanName(input.getStringOr("OwnerName", "?"));
pages = input.read("Pages", PAGE_CODEC.listOf(0, TrophyPanelRules.MAX_PAGES))
.map(TrophyPanelBlockEntity::sanitizePages).orElseGet(() -> List.of(defaultPage()));
currentPage = Math.clamp(input.getIntOr("CurrentPage", 0), 0, pages.size() - 1);
visibleSnapshot = input.read(CLIENT_SNAPSHOT_TAG, CLIENT_SNAPSHOT_CODEC)
.orElseGet(() -> ClientSnapshot.empty(metricFor(currentPage().metricId())));
}
@Override
public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override
public CompoundTag getUpdateTag(HolderLookup.Provider lookup) {
CompoundTag tag = saveCustomOnly(lookup);
tag.store(CLIENT_SNAPSHOT_TAG, CLIENT_SNAPSHOT_CODEC, visibleSnapshot);
return tag;
}
@Override
public net.minecraft.world.level.Level level() {
return level;
}
@Override
public Vec3 position() {
return Vec3.atCenterOf(worldPosition);
}
@Override
public float getVisualRotationYInDegrees() {
return getBlockState().getValue(TrophyPanelBlock.FACING).toYRot();
}
private void syncPersistent() {
setChanged();
if (level != null) level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), Block.UPDATE_CLIENTS);
}
private boolean writable() {
return dataVersion <= DATA_VERSION;
}
private static List<Page> sanitizePages(List<Page> loaded) {
List<Page> valid = loaded.stream().filter(page -> SanctuaryRankingApi.metric(page.metricId()).isPresent())
.limit(TrophyPanelRules.MAX_PAGES).toList();
return valid.isEmpty() ? List.of(defaultPage()) : List.copyOf(valid);
}
private static Page defaultPage() {
return new Page(SanctuaryRankingApi.defaultMetric().id(), ItemStack.EMPTY);
}
private static RankingMetric metricFor(Identifier id) {
return SanctuaryRankingApi.metric(id).orElse(SanctuaryRankingApi.defaultMetric());
}
private static String cleanName(String name) {
if (name == null || name.isBlank()) return "?";
String clean = name.strip();
return clean.length() > 32 ? clean.substring(0, 32) : clean;
}
public enum ConfigureResult { SUCCESS, NO_METRIC, NEWER_DATA }
public record Page(Identifier metricId, ItemStack icon) {
public Page {
metricId = metricId == null ? SanctuaryRankingApi.defaultMetric().id() : metricId;
// The registry item is enough for an icon. Player components (containers, names, lore) never persist or sync.
icon = icon == null || icon.isEmpty() ? ItemStack.EMPTY : new ItemStack(icon.getItem());
}
}
public record ClientEntry(String name, long value, int rank) {
public ClientEntry {
name = cleanName(name);
value = Math.max(0L, value);
rank = Math.max(0, rank);
}
}
public record ClientSnapshot(Identifier metricId, String translationKey, String unitKey,
List<ClientEntry> podium, long ownerValue, int ownerRank) {
public ClientSnapshot {
translationKey = translationKey == null || translationKey.isBlank()
? "metric.sanctuary.ranking.unknown" : translationKey;
unitKey = RankingUnit.byKey(unitKey).key();
podium = podium == null ? List.of() : List.copyOf(podium.stream()
.limit(RankingSnapshot.MAX_PODIUM_SIZE).toList());
ownerValue = Math.max(0L, ownerValue);
ownerRank = Math.max(0, ownerRank);
}
static ClientSnapshot from(RankingSnapshot snapshot) {
return new ClientSnapshot(snapshot.metric().id(), snapshot.metric().translationKey(),
snapshot.metric().unit().key(), snapshot.podium().stream()
.map(entry -> new ClientEntry(entry.playerName(), entry.value(), entry.rank())).toList(),
snapshot.owner().value(), snapshot.owner().rank());
}
static ClientSnapshot empty(RankingMetric metric) {
return new ClientSnapshot(metric.id(), metric.translationKey(), metric.unit().key(), List.of(), 0L, 0);
}
public RankingUnit unit() {
return RankingUnit.byKey(unitKey);
}
}
}
@@ -5,7 +5,6 @@ import fr.koka99cab.sanctuary26.redstoner.client.gui.ComputerScreen;
import fr.koka99cab.sanctuary26.redstoner.client.gui.PersonalComputerScreen;
import fr.koka99cab.sanctuary26.redstoner.client.gui.StorageTerminalScreen;
import fr.koka99cab.sanctuary26.redstoner.client.renderer.DrawerBlockEntityRenderer;
import fr.koka99cab.sanctuary26.redstoner.client.renderer.TrophyPanelBlockEntityRenderer;
import fr.koka99cab.sanctuary26.redstoner.network.OpenComputerPayload;
import fr.koka99cab.sanctuary26.redstoner.network.OpenPersonalComputerPayload;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerMenus;
@@ -20,8 +19,6 @@ public final class RedStonerClient implements ClientModInitializer {
public void onInitializeClient() {
BlockEntityRendererRegistry.register(RedStonerRegistries.DRAWER_BLOCK_ENTITY, DrawerBlockEntityRenderer::new);
BlockEntityRendererRegistry.register(RedStonerRegistries.LEGACY_DRAWER_BLOCK_ENTITY, DrawerBlockEntityRenderer::new);
BlockEntityRendererRegistry.register(RedStonerRegistries.TROPHY_PANEL_BLOCK_ENTITY,
TrophyPanelBlockEntityRenderer::new);
MenuScreens.register(RedStonerMenus.STORAGE_TERMINAL, StorageTerminalScreen::new);
ClientPlayNetworking.registerGlobalReceiver(OpenComputerPayload.TYPE, (payload, context) ->
context.client().execute(() -> context.client().gui.setScreen(new ComputerScreen(payload))));
@@ -2,15 +2,12 @@ package fr.koka99cab.sanctuary26.redstoner.client.gui;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalMenu;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalRules;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalSort;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalStatus;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.ChatFormatting;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.input.MouseButtonEvent;
@@ -31,10 +28,8 @@ public final class StorageTerminalScreen extends AbstractContainerScreen<Storage
private static final int SCROLL_WIDTH = 4;
private int requestedPage;
private EditBox searchBox;
private Button sortButton;
private String searchValue = "";
private String sentSearchValue = "";
private StorageTerminalSort sortMode = StorageTerminalSort.ALPHABETICAL;
public StorageTerminalScreen(StorageTerminalMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title, 176, 222);
@@ -45,16 +40,13 @@ public final class StorageTerminalScreen extends AbstractContainerScreen<Storage
protected void init() {
super.init();
titleLabelY = 6;
searchBox = addRenderableWidget(new EditBox(font, leftPos + 8, topPos + 2, 96, 14,
searchBox = addRenderableWidget(new EditBox(font, leftPos + 8, topPos + 2, 126, 14,
Component.translatable("screen.redstoner.storage_terminal.search")));
searchBox.setMaxLength(StorageTerminalRules.MAX_SEARCH_LENGTH);
searchBox.setHint(Component.translatable("screen.redstoner.storage_terminal.search"));
searchBox.setValue(searchValue);
searchBox.setResponder(this::searchChanged);
sortButton = addRenderableWidget(Button.builder(sortButtonLabel(), button -> requestSort())
.bounds(leftPos + 108, topPos + 2, 34, 14)
.tooltip(Tooltip.create(sortTooltip()))
.build());
setInitialFocus(searchBox);
}
@Override
@@ -122,10 +114,6 @@ public final class StorageTerminalScreen extends AbstractContainerScreen<Storage
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
if (searchBox != null && searchBox.isFocused()
&& !searchBox.isMouseOver(event.x(), event.y())) {
clearSearchFocus();
}
if (event.button() == 0
&& isHovering(SCROLL_X - 2, TERMINAL_TOP, SCROLL_WIDTH + 4, TERMINAL_HEIGHT,
event.x(), event.y())) {
@@ -139,9 +127,9 @@ public final class StorageTerminalScreen extends AbstractContainerScreen<Storage
@Override
public boolean keyPressed(KeyEvent event) {
if (searchBox != null && searchBox.isFocused() && minecraft != null
&& minecraft.options.keyInventory.matches(event)) {
clearSearchFocus();
if (searchBox != null && searchBox.isFocused() && event.key() != 256) {
searchBox.keyPressed(event);
return true;
}
return super.keyPressed(event);
}
@@ -160,28 +148,6 @@ public final class StorageTerminalScreen extends AbstractContainerScreen<Storage
}
}
private void requestSort() {
sortMode = sortMode.next();
requestedPage = 0;
sortButton.setMessage(sortButtonLabel());
sortButton.setTooltip(Tooltip.create(sortTooltip()));
sendMenuButton(StorageTerminalMenu.SORT_BUTTON_BASE + sortMode.ordinal());
}
private Component sortButtonLabel() {
return Component.literal(switch (sortMode) {
case ALPHABETICAL -> "A→Z";
case COUNT_ASCENDING -> "1↑";
case COUNT_DESCENDING -> "1↓";
});
}
private Component sortTooltip() {
return Component.translatable("screen.redstoner.storage_terminal.sort",
Component.translatable("screen.redstoner.storage_terminal.sort."
+ sortMode.translationSuffix()));
}
private void searchChanged(String value) {
String previous = sentSearchValue;
searchValue = value;
@@ -207,11 +173,6 @@ public final class StorageTerminalScreen extends AbstractContainerScreen<Storage
}
}
private void clearSearchFocus() {
searchBox.setFocused(false);
setFocused(null);
}
private void extractScrollBar(GuiGraphicsExtractor graphics) {
int x = leftPos + SCROLL_X;
int y = topPos + TERMINAL_TOP;
@@ -1,89 +0,0 @@
package fr.koka99cab.sanctuary26.redstoner.client.renderer;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import fr.koka99cab.sanctuary26.redstoner.block.TrophyPanelBlock;
import fr.koka99cab.sanctuary26.redstoner.block.entity.TrophyPanelBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.client.renderer.state.TrophyPanelRenderState;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingUnit;
import net.minecraft.client.gui.Font;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.network.chat.Component;
import net.minecraft.util.FormattedCharSequence;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.Vec3;
public final class TrophyPanelBlockEntityRenderer
implements BlockEntityRenderer<TrophyPanelBlockEntity, TrophyPanelRenderState> {
private static final int MAX_TEXT_WIDTH = 90;
private static final float TEXT_SCALE = 0.010416667F;
private static final float[] LINE_HEIGHTS = {0.70F, 0.59F, 0.48F, 0.37F};
private static final int TEXT_COLOR = 0xFF3D281A;
private final Font font;
public TrophyPanelBlockEntityRenderer(BlockEntityRendererProvider.Context context) {
font = context.font();
}
@Override
public TrophyPanelRenderState createRenderState() {
return new TrophyPanelRenderState();
}
@Override
public void extractRenderState(TrophyPanelBlockEntity panel, TrophyPanelRenderState state,
float tickProgress, Vec3 cameraPos, ModelFeatureRenderer.CrumblingOverlay crumblingOverlay) {
BlockEntityRenderer.super.extractRenderState(panel, state, tickProgress, cameraPos, crumblingOverlay);
state.facing = panel.getBlockState().getValue(TrophyPanelBlock.FACING);
ItemStack icon = panel.currentPage().icon();
TrophyPanelBlockEntity.ClientSnapshot snapshot = panel.visibleSnapshot();
RankingUnit unit = snapshot.unit();
Component metric = !icon.isEmpty() ? Component.translatable(snapshot.translationKey(), icon.getHoverName())
: Component.translatable(snapshot.translationKey());
state.lines[0] = Component.translatable("block.redstoner.trophy_panel.page_title",
panel.pageNumber(), panel.pageCount(), metric);
if (snapshot.podium().isEmpty()) {
state.lines[1] = Component.translatable("block.redstoner.trophy_panel.no_score");
state.lines[2] = Component.empty();
state.lines[3] = Component.empty();
} else {
for (int index = 0; index < 3; index++) {
if (index >= snapshot.podium().size()) {
state.lines[index + 1] = Component.empty();
continue;
}
TrophyPanelBlockEntity.ClientEntry entry = snapshot.podium().get(index);
state.lines[index + 1] = Component.translatable("block.redstoner.trophy_panel.podium",
entry.rank(), entry.name(), unit.format(entry.value()));
}
}
}
@Override
public void submit(TrophyPanelRenderState state, PoseStack poseStack,
SubmitNodeCollector submitNodeCollector, CameraRenderState cameraRenderState) {
float yRotation = -state.facing.toYRot();
for (int index = 0; index < TrophyPanelRenderState.LINE_COUNT; index++) {
submitText(state.lines[index], LINE_HEIGHTS[index], state, poseStack, submitNodeCollector, yRotation);
}
}
private void submitText(Component component, float y, TrophyPanelRenderState state,
PoseStack poseStack, SubmitNodeCollector submitNodeCollector, float yRotation) {
FormattedCharSequence text = font.split(component, MAX_TEXT_WIDTH).stream().findFirst()
.orElse(FormattedCharSequence.EMPTY);
float x = -font.width(text) / 2.0F;
poseStack.pushPose();
poseStack.translate(0.5F, y, 0.5F);
poseStack.mulPose(Axis.YP.rotationDegrees(yRotation));
poseStack.translate(0.0F, 0.0F, -0.331F);
poseStack.scale(TEXT_SCALE, -TEXT_SCALE, TEXT_SCALE);
submitNodeCollector.submitText(poseStack, x, 0.0F, text, false, Font.DisplayMode.POLYGON_OFFSET,
state.lightCoords, TEXT_COLOR, 0, 0);
poseStack.popPose();
}
}
@@ -1,13 +0,0 @@
package fr.koka99cab.sanctuary26.redstoner.client.renderer.state;
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
public final class TrophyPanelRenderState extends BlockEntityRenderState {
public static final int LINE_COUNT = 4;
public final Component[] lines = {
Component.empty(), Component.empty(), Component.empty(), Component.empty()
};
public Direction facing = Direction.NORTH;
}
@@ -6,12 +6,10 @@ import fr.koka99cab.sanctuary26.redstoner.block.DrawerBlock;
import fr.koka99cab.sanctuary26.redstoner.block.PersonalComputerBlock;
import fr.koka99cab.sanctuary26.redstoner.block.ParticuleurBlock;
import fr.koka99cab.sanctuary26.redstoner.block.StorageTerminalBlock;
import fr.koka99cab.sanctuary26.redstoner.block.TrophyPanelBlock;
import fr.koka99cab.sanctuary26.redstoner.block.entity.ComputerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.DrawerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.PersonalComputerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.ParticuleurBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.TrophyPanelBlockEntity;
import java.util.function.Function;
import net.fabricmc.fabric.api.event.player.AttackBlockCallback;
import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;
@@ -73,12 +71,6 @@ public final class RedStonerRegistries {
BlockBehaviour.Properties.of().mapColor(MapColor.COLOR_GRAY).strength(4.0F, 6.0F).sound(SoundType.METAL)
.requiresCorrectToolForDrops().noOcclusion().setId(blockKey(RedStonerMod.id("storage_terminal")))
);
public static final TrophyPanelBlock TROPHY_PANEL = registerBlock(
RedStonerMod.id("trophy_panel"),
TrophyPanelBlock::new,
BlockBehaviour.Properties.of().mapColor(MapColor.WOOD).strength(2.0F).sound(SoundType.WOOD)
.requiresCorrectToolForDrops().noOcclusion().setId(blockKey(RedStonerMod.id("trophy_panel")))
);
public static final ComputerBlock LEGACY_EIGHT_BIT_COMPUTER = registerBlock(
LEGACY_COMPUTER_ID, ComputerBlock::new, computerProperties(LEGACY_COMPUTER_ID, MapColor.COLOR_RED));
@@ -100,7 +92,6 @@ public final class RedStonerRegistries {
public static final Item PARTICULEUR_ITEM = registerBlockItem(RedStonerMod.id("particuleur"), PARTICULEUR);
public static final Item STORAGE_TERMINAL_ITEM = registerBlockItem(
RedStonerMod.id("storage_terminal"), STORAGE_TERMINAL);
public static final Item TROPHY_PANEL_ITEM = registerBlockItem(RedStonerMod.id("trophy_panel"), TROPHY_PANEL);
public static final Item LEGACY_EIGHT_BIT_COMPUTER_ITEM = registerBlockItem(LEGACY_COMPUTER_ID, LEGACY_EIGHT_BIT_COMPUTER);
public static final Item LEGACY_CONTROLLER_ITEM = registerBlockItem(LEGACY_CONTROLLER_ID, LEGACY_CONTROLLER);
@@ -124,8 +115,6 @@ public final class RedStonerRegistries {
RedStonerMod.id("particuleur"), ParticuleurBlockEntity::new, PARTICULEUR);
public static final BlockEntityType<ParticuleurBlockEntity> LEGACY_PARTICULEUR_BLOCK_ENTITY = registerBlockEntity(
LEGACY_PARTICULEUR_ID, ParticuleurBlockEntity::new, LEGACY_PARTICULEUR);
public static final BlockEntityType<TrophyPanelBlockEntity> TROPHY_PANEL_BLOCK_ENTITY = registerBlockEntity(
RedStonerMod.id("trophy_panel"), TrophyPanelBlockEntity::new, TROPHY_PANEL);
public static final CreativeModeTab CREATIVE_TAB = Registry.register(
BuiltInRegistries.CREATIVE_MODE_TAB,
@@ -138,7 +127,6 @@ public final class RedStonerRegistries {
output.accept(CONTROLLER_ITEM);
output.accept(PARTICULEUR_ITEM);
output.accept(STORAGE_TERMINAL_ITEM);
output.accept(TROPHY_PANEL_ITEM);
})
.build()
);
@@ -154,7 +142,7 @@ public final class RedStonerRegistries {
}
initialized = true;
registerDrawerAttack();
RedStonerMod.LOGGER.info("[{}] Registered seven active blocks and five legacy block aliases.", RedStonerMod.DISPLAY_NAME);
RedStonerMod.LOGGER.info("[{}] Registered six active blocks and five legacy block aliases.", RedStonerMod.DISPLAY_NAME);
}
public static boolean isController(BlockState state) {
@@ -27,7 +27,6 @@ public final class StorageTerminalMenu extends AbstractContainerMenu {
public static final int PAGE_BUTTON_BASE = 10;
public static final int SEARCH_CLEAR_BUTTON = 100;
public static final int SEARCH_BACKSPACE_BUTTON = 101;
public static final int SORT_BUTTON_BASE = 200;
public static final int SEARCH_CODEPOINT_BUTTON_BASE = 1_000;
private static final int PLAYER_INVENTORY_Y = 140;
private static final int REFRESH_INTERVAL = 20;
@@ -50,7 +49,6 @@ public final class StorageTerminalMenu extends AbstractContainerMenu {
private final StorageTerminalLayout layout = new StorageTerminalLayout();
private List<StorageTerminalInventory.Entry> entries = List.of();
private String searchQuery = "";
private StorageTerminalSort sortMode = StorageTerminalSort.ALPHABETICAL;
private int refreshCooldown = REFRESH_INTERVAL;
public StorageTerminalMenu(int containerId, Inventory inventory) {
@@ -133,9 +131,6 @@ public final class StorageTerminalMenu extends AbstractContainerMenu {
if (searchQuery.isEmpty()) return true;
searchQuery = searchQuery.substring(0,
searchQuery.offsetByCodePoints(searchQuery.length(), -1));
} else if (buttonId >= SORT_BUTTON_BASE
&& buttonId < SORT_BUTTON_BASE + StorageTerminalSort.values().length) {
sortMode = StorageTerminalSort.values()[buttonId - SORT_BUTTON_BASE];
} else {
int codePoint = buttonId - SEARCH_CODEPOINT_BUTTON_BASE;
if (!isSearchCodePoint(codePoint)) return false;
@@ -216,7 +211,7 @@ public final class StorageTerminalMenu extends AbstractContainerMenu {
if (serverPlayer == null || terminalPos == null) return;
discovery = StorageTerminalNetwork.discover(serverPlayer, terminalPos);
List<StorageTerminalInventory.Entry> liveEntries =
sortMode.order(StorageTerminalNetwork.filteredEntries(discovery, searchQuery));
StorageTerminalNetwork.filteredEntries(discovery, searchQuery);
layout.update(liveEntries, resetOrder);
int page = discovery.status() == StorageTerminalStatus.READY
@@ -5,16 +5,12 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.ShulkerBoxBlock;
/** Server-side matching for the bounded terminal search field. */
public final class StorageTerminalSearch {
private static final int SHULKER_SLOT_COUNT = 27;
private static final Pattern COMBINING_MARKS = Pattern.compile("\\p{M}+");
private static final Pattern NON_ALPHANUMERIC = Pattern.compile("[^\\p{L}\\p{N}]+");
@@ -42,27 +38,14 @@ public final class StorageTerminalSearch {
}
private static boolean matches(ItemStack stack, String[] terms) {
StringBuilder searchable = new StringBuilder();
appendSearchableText(searchable, stack);
if (stack.getItem() instanceof BlockItem blockItem
&& blockItem.getBlock() instanceof ShulkerBoxBlock) {
var contents = stack.get(DataComponents.CONTAINER);
if (contents != null) {
contents.nonEmptyItemCopyStream().limit(SHULKER_SLOT_COUNT).forEach(contained ->
appendSearchableText(searchable, contained));
}
}
StringBuilder searchable = new StringBuilder(stack.getHoverName().getString());
Identifier id = BuiltInRegistries.ITEM.getKey(stack.getItem());
if (id != null) searchable.append(' ').append(id);
searchable.append(' ').append(stack.getItem().getDescriptionId());
String normalized = normalize(searchable.toString());
for (String term : terms) {
if (!normalized.contains(term)) return false;
}
return true;
}
private static void appendSearchableText(StringBuilder searchable, ItemStack stack) {
searchable.append(' ').append(stack.getHoverName().getString());
Identifier id = BuiltInRegistries.ITEM.getKey(stack.getItem());
if (id != null) searchable.append(' ').append(id);
searchable.append(' ').append(stack.getItem().getDescriptionId());
}
}
@@ -1,59 +0,0 @@
package fr.koka99cab.sanctuary26.redstoner.storage;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemStack;
/** Explicit server-side ordering modes for terminal entries. */
public enum StorageTerminalSort {
ALPHABETICAL("alphabetical"),
COUNT_ASCENDING("count_ascending"),
COUNT_DESCENDING("count_descending");
private final String translationSuffix;
StorageTerminalSort(String translationSuffix) {
this.translationSuffix = translationSuffix;
}
public String translationSuffix() {
return translationSuffix;
}
public StorageTerminalSort next() {
StorageTerminalSort[] values = values();
return values[(ordinal() + 1) % values.length];
}
public List<StorageTerminalInventory.Entry> order(
List<StorageTerminalInventory.Entry> entries) {
List<StorageTerminalInventory.Entry> ordered = new ArrayList<>(entries);
ordered.sort(comparator());
return List.copyOf(ordered);
}
private Comparator<StorageTerminalInventory.Entry> comparator() {
Comparator<StorageTerminalInventory.Entry> alphabetical = Comparator
.comparing((StorageTerminalInventory.Entry entry) -> alphabeticalName(entry.stack()))
.thenComparing(entry -> itemId(entry.stack()));
return switch (this) {
case ALPHABETICAL -> alphabetical;
case COUNT_ASCENDING -> Comparator
.comparingInt(StorageTerminalInventory.Entry::total).thenComparing(alphabetical);
case COUNT_DESCENDING -> Comparator
.comparingInt(StorageTerminalInventory.Entry::total).reversed().thenComparing(alphabetical);
};
}
private static String alphabeticalName(ItemStack stack) {
return StorageTerminalSearch.normalize(stack.getHoverName().getString());
}
private static String itemId(ItemStack stack) {
Identifier id = BuiltInRegistries.ITEM.getKey(stack.getItem());
return id == null ? "" : id.toString();
}
}
@@ -1,37 +0,0 @@
package fr.koka99cab.sanctuary26.redstoner.trophy;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.UUID;
import net.minecraft.resources.Identifier;
/** Pure bounded rules shared by the trophy panel and its smoke tests. */
public final class TrophyPanelRules {
public static final int MAX_PAGES = 16;
private TrophyPanelRules() {}
public static boolean canConfigure(UUID owner, UUID actor, boolean operator) {
return operator || owner != null && owner.equals(actor);
}
public static int nextPage(int currentPage, int pageCount) {
return pageByOffset(currentPage, pageCount, 1);
}
public static int previousPage(int currentPage, int pageCount) {
return pageByOffset(currentPage, pageCount, -1);
}
public static int pageByOffset(int currentPage, int pageCount, int offset) {
return pageCount <= 0 ? 0 : Math.floorMod(currentPage + offset, pageCount);
}
public static List<Identifier> mergeMetricIds(List<Identifier> current, List<Identifier> offered) {
LinkedHashSet<Identifier> merged = new LinkedHashSet<>();
if (current != null) merged.addAll(current);
if (offered != null) merged.addAll(offered);
return List.copyOf(new ArrayList<>(merged).subList(0, Math.min(MAX_PAGES, merged.size())));
}
}
@@ -1,8 +0,0 @@
{
"variants": {
"facing=north": { "model": "redstoner:block/trophy_panel" },
"facing=east": { "model": "redstoner:block/trophy_panel", "y": 90 },
"facing=south": { "model": "redstoner:block/trophy_panel", "y": 180 },
"facing=west": { "model": "redstoner:block/trophy_panel", "y": 270 }
}
}
@@ -1,6 +0,0 @@
{
"model": {
"type": "minecraft:model",
"model": "redstoner:block/trophy_panel"
}
}
@@ -55,28 +55,8 @@
"screen.redstoner.storage_terminal.search": "Search…",
"screen.redstoner.storage_terminal.no_results": "No items found.",
"screen.redstoner.storage_terminal.summary": "%s/%s · %s blocks · %s types",
"screen.redstoner.storage_terminal.sort": "Sort: %s",
"screen.redstoner.storage_terminal.sort.alphabetical": "alphabetical",
"screen.redstoner.storage_terminal.sort.count_ascending": "quantity, ascending",
"screen.redstoner.storage_terminal.sort.count_descending": "quantity, descending",
"screen.redstoner.storage_terminal.error.invalid_terminal": "The terminal is no longer accessible.",
"screen.redstoner.storage_terminal.error.disconnected": "No connected chest or barrel.",
"screen.redstoner.storage_terminal.error.too_large": "Network too large (maximum 128 storage blocks).",
"screen.redstoner.storage_terminal.error.locked": "The network contains a locked storage block.",
"block.redstoner.trophy_panel": "Trophy Panel",
"block.redstoner.trophy_panel.page_title": "[%s/%s] %s",
"block.redstoner.trophy_panel.podium": "#%s %s · %s",
"block.redstoner.trophy_panel.no_score": "No score yet",
"block.redstoner.trophy_panel.champion": "#1 %s · %s",
"block.redstoner.trophy_panel.owner_rank": "%s · #%s · %s",
"block.redstoner.trophy_panel.owner_unranked": "%s · unranked",
"message.redstoner.trophy_panel.not_owner": "Only the owner or an operator can configure this panel.",
"message.redstoner.trophy_panel.configured": "Compatible pages added; the item was not consumed.",
"message.redstoner.trophy_panel.no_metric": "This item has no compatible statistic.",
"message.redstoner.trophy_panel.newer_data": "This panel uses a newer data format and remains read-only.",
"message.redstoner.trophy_panel.page": "Page %s/%s",
"message.redstoner.trophy_panel.header": "%s · page %s/%s",
"message.redstoner.trophy_panel.podium": "#%s %s · %s",
"message.redstoner.trophy_panel.owner_rank": "%s: rank #%s · %s",
"message.redstoner.trophy_panel.owner_unranked": "%s: no score"
"screen.redstoner.storage_terminal.error.locked": "The network contains a locked storage block."
}
@@ -55,28 +55,8 @@
"screen.redstoner.storage_terminal.search": "Rechercher…",
"screen.redstoner.storage_terminal.no_results": "Aucun objet trouvé.",
"screen.redstoner.storage_terminal.summary": "%s/%s · %s blocs · %s types",
"screen.redstoner.storage_terminal.sort": "Tri : %s",
"screen.redstoner.storage_terminal.sort.alphabetical": "alphabétique",
"screen.redstoner.storage_terminal.sort.count_ascending": "quantité croissante",
"screen.redstoner.storage_terminal.sort.count_descending": "quantité décroissante",
"screen.redstoner.storage_terminal.error.invalid_terminal": "Le terminal n'est plus accessible.",
"screen.redstoner.storage_terminal.error.disconnected": "Aucun coffre ou baril connecté.",
"screen.redstoner.storage_terminal.error.too_large": "Réseau trop grand (128 stockages maximum).",
"screen.redstoner.storage_terminal.error.locked": "Le réseau contient un stockage verrouillé.",
"block.redstoner.trophy_panel": "Panneau de trophées",
"block.redstoner.trophy_panel.page_title": "[%s/%s] %s",
"block.redstoner.trophy_panel.podium": "#%s %s · %s",
"block.redstoner.trophy_panel.no_score": "Aucun score",
"block.redstoner.trophy_panel.champion": "#1 %s · %s",
"block.redstoner.trophy_panel.owner_rank": "%s · #%s · %s",
"block.redstoner.trophy_panel.owner_unranked": "%s · non classé",
"message.redstoner.trophy_panel.not_owner": "Seul le propriétaire ou un opérateur peut configurer ce panneau.",
"message.redstoner.trophy_panel.configured": "Pages compatibles ajoutées ; l’objet n’a pas été consommé.",
"message.redstoner.trophy_panel.no_metric": "Cet objet ne fournit aucune statistique compatible.",
"message.redstoner.trophy_panel.newer_data": "Ce panneau utilise un format de données plus récent et reste en lecture seule.",
"message.redstoner.trophy_panel.page": "Page %s/%s",
"message.redstoner.trophy_panel.header": "%s · page %s/%s",
"message.redstoner.trophy_panel.podium": "#%s %s · %s",
"message.redstoner.trophy_panel.owner_rank": "%s : rang #%s · %s",
"message.redstoner.trophy_panel.owner_unranked": "%s : aucun score"
"screen.redstoner.storage_terminal.error.locked": "Le réseau contient un stockage verrouillé."
}
@@ -55,28 +55,8 @@
"screen.redstoner.storage_terminal.search": "Поиск…",
"screen.redstoner.storage_terminal.no_results": "Предметы не найдены.",
"screen.redstoner.storage_terminal.summary": "%s/%s · блоков: %s · типов: %s",
"screen.redstoner.storage_terminal.sort": "Сортировка: %s",
"screen.redstoner.storage_terminal.sort.alphabetical": "по алфавиту",
"screen.redstoner.storage_terminal.sort.count_ascending": "по возрастанию количества",
"screen.redstoner.storage_terminal.sort.count_descending": "по убыванию количества",
"screen.redstoner.storage_terminal.error.invalid_terminal": "Терминал больше недоступен.",
"screen.redstoner.storage_terminal.error.disconnected": "Нет подключённых сундуков или бочек.",
"screen.redstoner.storage_terminal.error.too_large": "Сеть слишком велика (не более 128 хранилищ).",
"screen.redstoner.storage_terminal.error.locked": "В сети есть запертое хранилище.",
"block.redstoner.trophy_panel": "Панель трофеев",
"block.redstoner.trophy_panel.page_title": "[%s/%s] %s",
"block.redstoner.trophy_panel.podium": "#%s %s · %s",
"block.redstoner.trophy_panel.no_score": "Результатов пока нет",
"block.redstoner.trophy_panel.champion": "#1 %s · %s",
"block.redstoner.trophy_panel.owner_rank": "%s · #%s · %s",
"block.redstoner.trophy_panel.owner_unranked": "%s · без места",
"message.redstoner.trophy_panel.not_owner": "Настраивать эту панель может только владелец или оператор.",
"message.redstoner.trophy_panel.configured": "Совместимые страницы добавлены; предмет не был израсходован.",
"message.redstoner.trophy_panel.no_metric": "У этого предмета нет совместимой статистики.",
"message.redstoner.trophy_panel.newer_data": "Панель использует более новый формат данных и доступна только для чтения.",
"message.redstoner.trophy_panel.page": "Страница %s/%s",
"message.redstoner.trophy_panel.header": "%s · страница %s/%s",
"message.redstoner.trophy_panel.podium": "#%s %s · %s",
"message.redstoner.trophy_panel.owner_rank": "%s: место #%s · %s",
"message.redstoner.trophy_panel.owner_unranked": "%s: результата нет"
"screen.redstoner.storage_terminal.error.locked": "В сети есть запертое хранилище."
}
@@ -1,21 +0,0 @@
{
"parent": "minecraft:block/block",
"textures": {
"particle": "minecraft:block/oak_planks",
"wood": "minecraft:block/oak_planks"
},
"elements": [
{
"from": [0, 0, 14],
"to": [16, 16, 16],
"faces": {
"north": { "texture": "#wood" },
"south": { "texture": "#wood" },
"west": { "texture": "#wood" },
"east": { "texture": "#wood" },
"up": { "texture": "#wood" },
"down": { "texture": "#wood" }
}
}
]
}
@@ -1 +1 @@
{"replace":false,"values":["redstoner:eight_bit_computer","redstoner:controller","redstoner:personal_computer","redstoner:particuleur","redstoner:storage_terminal","redstoner:trophy_panel","redstone_computer:redstone_computer","redstone_computer:redstone_controller","redstone_computer:personal_computer","particuleur:particuleur"]}
{"replace":false,"values":["redstoner:eight_bit_computer","redstoner:controller","redstoner:personal_computer","redstoner:particuleur","redstoner:storage_terminal","redstone_computer:redstone_computer","redstone_computer:redstone_controller","redstone_computer:personal_computer","particuleur:particuleur"]}
@@ -1 +1 @@
{"replace":false,"values":["redstoner:eight_bit_computer","redstoner:controller","redstoner:personal_computer","redstoner:particuleur","redstoner:storage_terminal","redstoner:trophy_panel","redstone_computer:redstone_computer","redstone_computer:redstone_controller","redstone_computer:personal_computer","particuleur:particuleur"]}
{"replace":false,"values":["redstoner:eight_bit_computer","redstoner:controller","redstoner:personal_computer","redstoner:particuleur","redstoner:storage_terminal","redstone_computer:redstone_computer","redstone_computer:redstone_controller","redstone_computer:personal_computer","particuleur:particuleur"]}
@@ -1,15 +0,0 @@
{
"type": "minecraft:block",
"pools": [
{
"rolls": 1,
"entries": [
{ "type": "minecraft:item", "name": "redstoner:trophy_panel" }
],
"conditions": [
{ "condition": "minecraft:survives_explosion" }
]
}
],
"random_sequence": "redstoner:blocks/trophy_panel"
}
@@ -1,19 +0,0 @@
{
"type": "minecraft:crafting_shaped",
"category": "redstone",
"pattern": [
"NGN",
"GRG",
"NSN"
],
"key": {
"N": "minecraft:gold_nugget",
"G": "minecraft:black_stained_glass_pane",
"R": "minecraft:redstone_lamp",
"S": "minecraft:oak_sign"
},
"result": {
"id": "redstoner:trophy_panel",
"count": 1
}
}
+1 -1
View File
@@ -30,7 +30,7 @@
"sanctuary26": {
"system_type": "gameplay-system",
"namespace": "redstoner",
"data_version": 4,
"data_version": 3,
"network_protocol": 7,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
@@ -14,7 +14,6 @@ import net.minecraft.world.SimpleContainer;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.component.ItemContainerContents;
public final class StorageTerminalSmoke {
private StorageTerminalSmoke() {
@@ -27,7 +26,6 @@ public final class StorageTerminalSmoke {
verifyPagination();
verifyAggregationAndTransfers();
verifySearch();
verifySorting();
verifyStableLayout();
}
@@ -117,14 +115,6 @@ public final class StorageTerminalSmoke {
ItemStack russian = new ItemStack(stackable(Items.REDSTONE), 3);
russian.set(DataComponents.CUSTOM_NAME, Component.literal("Красный камень"));
container.setItem(2, russian);
Holder<Item> shulkerItem = new Holder.Direct<>(Items.SHULKER_BOX, DataComponentMap.builder()
.set(DataComponents.MAX_STACK_SIZE, 1).build());
ItemStack shulker = new ItemStack(shulkerItem);
ItemStack nestedDiamond = new ItemStack(stackable(Items.DIAMOND), 7);
nestedDiamond.set(DataComponents.CUSTOM_NAME, Component.literal("Trésor intérieur"));
shulker.set(DataComponents.CONTAINER, ItemContainerContents.fromItems(List.of(
nestedDiamond, new ItemStack(stackable(Items.OAK_LOG), 32))));
container.setItem(3, shulker);
List<StorageTerminalInventory.Entry> entries = StorageTerminalInventory.aggregate(List.of(container));
require(StorageTerminalSearch.filter(entries, "minecraft:cobblestone").size() == 1,
@@ -135,12 +125,6 @@ public final class StorageTerminalSmoke {
"Terminal search did not ignore accents in a custom name");
require(StorageTerminalSearch.filter(entries, "красный камень").size() == 1,
"Terminal search did not preserve non-Latin keyboard input");
require(StorageTerminalSearch.filter(entries, "minecraft:oak_log").size() == 1,
"Terminal search did not find an item id inside a Shulker box");
require(StorageTerminalSearch.filter(entries, "tresor interieur oak log").size() == 1,
"Terminal search did not index the composed contents of a Shulker box");
require(StorageTerminalSearch.filter(entries, "shulker box").size() == 1,
"Searching Shulker contents broke matching against the box itself");
require(StorageTerminalSearch.filter(entries, "missing").isEmpty(),
"Terminal search retained an unrelated item");
}
@@ -150,47 +134,6 @@ public final class StorageTerminalSmoke {
.set(DataComponents.MAX_STACK_SIZE, 64).build());
}
private static void verifySorting() {
ItemStack eclair = new ItemStack(stackable(Items.STONE));
eclair.set(DataComponents.CUSTOM_NAME, Component.literal("Éclair"));
ItemStack banana = new ItemStack(stackable(Items.DIRT));
banana.set(DataComponents.CUSTOM_NAME, Component.literal("Banane"));
ItemStack zebra = new ItemStack(stackable(Items.DIAMOND));
zebra.set(DataComponents.CUSTOM_NAME, Component.literal("Zèbre"));
List<StorageTerminalInventory.Entry> entries = List.of(
new StorageTerminalInventory.Entry(eclair, 64),
new StorageTerminalInventory.Entry(banana, 8),
new StorageTerminalInventory.Entry(zebra, 2));
require(names(StorageTerminalSort.ALPHABETICAL.order(entries))
.equals(List.of("Banane", "Éclair", "Zèbre")),
"Alphabetical terminal sorting ignored names or accent folding");
require(names(StorageTerminalSort.COUNT_ASCENDING.order(entries))
.equals(List.of("Zèbre", "Banane", "Éclair")),
"Ascending terminal quantity sorting is incorrect");
require(names(StorageTerminalSort.COUNT_DESCENDING.order(entries))
.equals(List.of("Éclair", "Banane", "Zèbre")),
"Descending terminal quantity sorting is incorrect");
require(StorageTerminalSort.ALPHABETICAL.next() == StorageTerminalSort.COUNT_ASCENDING
&& StorageTerminalSort.COUNT_ASCENDING.next() == StorageTerminalSort.COUNT_DESCENDING
&& StorageTerminalSort.COUNT_DESCENDING.next() == StorageTerminalSort.ALPHABETICAL,
"Terminal sorting modes do not cycle deterministically");
StorageTerminalLayout stable = new StorageTerminalLayout();
stable.update(StorageTerminalSort.COUNT_DESCENDING.order(entries), true);
List<StorageTerminalInventory.Entry> changedCounts = List.of(
new StorageTerminalInventory.Entry(eclair, 1),
new StorageTerminalInventory.Entry(banana, 80),
new StorageTerminalInventory.Entry(zebra, 2));
stable.update(StorageTerminalSort.COUNT_DESCENDING.order(changedCounts), false);
require(names(stable.page(0).subList(0, 3)).equals(List.of("Éclair", "Banane", "Zèbre")),
"A periodic quantity refresh moved terminal entries under the cursor");
}
private static List<String> names(List<StorageTerminalInventory.Entry> entries) {
return entries.stream().map(entry -> entry.stack().getHoverName().getString()).toList();
}
private static void verifyStableLayout() {
StorageTerminalInventory.Entry stone = new StorageTerminalInventory.Entry(
new ItemStack(stackable(Items.STONE)), 64);
@@ -1,37 +0,0 @@
package fr.koka99cab.sanctuary26.redstoner.trophy;
import java.util.List;
import java.util.UUID;
import net.minecraft.resources.Identifier;
public final class TrophyPanelRulesSmoke {
private TrophyPanelRulesSmoke() {}
public static void main(String[] args) {
UUID owner = UUID.randomUUID();
UUID visitor = UUID.randomUUID();
require(TrophyPanelRules.canConfigure(owner, owner, false), "The owner cannot configure their panel");
require(!TrophyPanelRules.canConfigure(owner, visitor, false), "A visitor can configure another panel");
require(TrophyPanelRules.canConfigure(owner, visitor, true), "An operator cannot repair a panel");
require(TrophyPanelRules.nextPage(0, 3) == 1 && TrophyPanelRules.nextPage(2, 3) == 0,
"Public page cycling does not wrap");
require(TrophyPanelRules.previousPage(0, 3) == 2 && TrophyPanelRules.previousPage(2, 3) == 1,
"Reverse public page cycling does not wrap");
require(TrophyPanelRules.pageByOffset(4, 0, 1) == 0,
"An empty panel produced an invalid page index");
Identifier first = Identifier.parse("sanctuary:first");
Identifier second = Identifier.parse("sanctuary:second");
List<Identifier> offered = java.util.stream.IntStream.range(0, TrophyPanelRules.MAX_PAGES + 5)
.mapToObj(index -> Identifier.parse("sanctuary:metric_" + index)).toList();
List<Identifier> merged = TrophyPanelRules.mergeMetricIds(List.of(first, second, first), offered);
require(merged.size() == TrophyPanelRules.MAX_PAGES, "Panel pages are not bounded");
require(merged.get(0).equals(first) && merged.get(1).equals(second),
"Existing page order was not preserved");
require(merged.stream().distinct().count() == merged.size(), "Duplicate metric pages were retained");
}
private static void require(boolean condition, String message) {
if (!condition) throw new IllegalStateException(message);
}
}
-93
View File
@@ -1,93 +0,0 @@
# Backroom
La Backroom est une dimension persistante de récupération et de construction. Elle n’est jamais
réinitialisée : routes, bases, coffres et bâtiments posés par les habitants restent dans le monde.
Sa zone architecturale forme un disque de 2 000 blocs autour de l’origine.
## Entrer et sortir
- S’accroupir et utiliser un lit hors de la Backroom déclenche l’entrée, sans remplacer l’usage
normal du lit.
- Avant toute téléportation, le serveur transfère atomiquement vers la Mailbox enregistrée les
43 emplacements vanilla, les 18 emplacements étendus, l’œuf compagnon et la cape équipée.
- Si aucune Mailbox extérieure n’est chargée, ou si elle ne peut pas recevoir l’intégralité du
colis, rien ne bouge et l’entrée est refusée. Une Mailbox construite dans la Backroom n’est jamais
une destination d’entrée et ne permet donc pas d’importer du matériel.
- L’identifiant de transaction est conservé par la Mailbox. Après une interruption, la connexion
suivante termine exactement une fois le vidage correspondant ou annule une transaction qui n’a
jamais atteint la Mailbox.
- Dans la Backroom, tout lit — généré ou posé par un joueur — ramène au lit de réapparition normal,
ou au point d’apparition global s’il n’existe plus. Le joueur conserve alors ses trouvailles.
- Aucun autre usage de lit ne définit un point de réapparition dans la dimension.
- Toute autre téléportation vers la Backroom est refusée. Une téléportation forcée vers l’extérieur
reste possible pour l’administration, mais replace d’abord le butin porté dans le registre : elle
ne constitue jamais une extraction réussie.
- Chaque arrivée se fait sur le dernier plancher du niveau 0. Le serveur libère exactement les deux
blocs nécessaires au joueur et pose une torche adjacente : elle peut être récupérée et devient le
premier outil d’exploration dans une dimension autrement noire.
## Objets perdus et coffres
Les piles qui disparaissent naturellement ou tombent dans le vide rejoignent un registre mondial
borné. Une mort de joueur dans la Backroom y replace aussi tout ce qu’il transportait. Chaque pile
réelle matérialisée reçoit un identifiant persistant : elle peut passer du registre à un coffre, puis
à un joueur et revenir au registre, mais elle ne peut jamais exister deux fois.
Une perte associée à un joueur lui reste réservée pendant 24 000 ticks avant de pouvoir rejoindre
les tirages historiques partagés ; une perte sans propriétaire est immédiatement partagée.
Chaque niveau génère exactement un coffre par cellule de 3 × 3 chunks dans les nouveaux chunks.
Tous les coffres vanilla de la Backroom, générés ou posés, deviennent des nœuds de butin renouvelés
toutes les 12 000 ticks lors de leur prochaine ouverture. Le renouvellement rend au registre les
objets perdus non pris, efface le contenu ordinaire et produit le plus souvent du vide ou des
ressources pauvres : terre, pierre, planches, torches, nourriture et outils simples. Le butin ancien
partagé et les objets de valeur restent rares. Reposer un coffre au même emplacement pendant le
même cycle ne crée pas un nouveau tirage ; multiplier les emplacements donne en revanche bien des
tirages indépendants, au prix des ressources trouvées ou produites sur place.
Pendant une expédition, chaque coffre distinct est un essai personnel. Le premier a 75 % de chance
de proposer une perte récente du joueur, le deuxième 90 %, le troisième 100 %, puis les suivants
65 %. Sans perte personnelle disponible, le coffre suit simplement son tirage général.
## Architecture verticale
La liste [Normal Levels I](https://backrooms-wiki.wikidot.com/normal-levels-i) sert uniquement de
référence d’ambiance et de vocabulaire. Les plans, palettes et règles ci-dessous sont originaux au
pack ; aucun texte ni visuel du wiki n’est embarqué.
La dimension couvre maintenant toute la hauteur de **Y = -64 à Y = 383**. Ses cinq niveaux sont
numérotés de haut en bas afin qu’une chute fasse toujours avancer vers le niveau suivant :
- **niveau 0 — Seuil, Y 295 à 383** : cinq plans de pièces jaunes et sableuses, avec couloirs,
cloisons à 90°, colonnes et quatre angles de salle. Chaque angle possède cinq variantes de
matériaux ; des trous rares relient les plans superposés ;
- **niveau 1 — Archives, Y 206 à 294** : quatre étages de bureaux et de bibliothèques pouvant
couvrir plusieurs chunks, avec longues rangées de rayonnages, grandes fenêtres intérieures,
salles de lecture et cloisons ouvertes ;
- **niveau 2 — Ville, Y 117 à 205** : bâtiments rapprochés séparés par de petites ruelles,
autoroutes orthogonales continues, tours à plusieurs étages et rares parcs enfermés sous le
plafond de la dimension ;
- **niveau 3 — Services, Y 28 à 116** : cinq plans de couloirs techniques en briques, grilles et
béton, dont toutes les jonctions de portes restent physiquement ouvertes ;
- **niveau 4 — Glitch, Y -64 à 27** : prismes de briques en grille 3D autour d’un toit où l’on peut
tomber, puis réseau déterministe de salles et couloirs. Chaque cellule du labyrinthe conserve une
route ouverte vers son origine ; certaines cloisons disparaissent pour fusionner des salles de
tailles différentes. De rares trous dans le dernier plancher mènent au vrai vide.
Les ouvertures entre deux niveaux sont calculées une seule fois puis appliquées à la fois au sol
supérieur et au plafond inférieur : une chute annoncée ne débouche donc pas sur un bloc fermé. Les
imperfections, morceaux suspendus et raccords volontairement cassés font partie de l’esthétique,
mais les portes des couloirs restent traversables. Chacun des cinq niveaux génère exactement un lit
de sortie par cellule de 3 × 3 chunks, sur un plancher praticable propre à sa zone. Il n’est donc
plus nécessaire de parcourir une centaine de chunks ou de remonter jusqu’aux Archives pour ressortir.
La Backroom ne possède ni ciel, ni soleil, ni lune, ni étoiles, ni lumière ambiante. Son biome ne
déclare aucune créature et le serveur retire aussi toute entité de catégorie monstre qui y serait
injectée par un autre système. Les matériaux très marqués d’Another World n’apparaissent que
ponctuellement aux anomalies. Aucun bloc précieux ne sert de matériau structurel ; de très rares
blocs de diamant restent cachés dans les fondations.
## Exploitation
Les données `sanctuary:backroom` utilisent leur propre `data_version`, ainsi que des limites pour les
transactions, pertes, coffres suivis, visites et arrivées récentes. Les opérateurs disposent de
`/backroom status` et `/backroom inspect <joueur>` pour contrôler les registres sans les modifier.
+16 -167
View File
@@ -124,14 +124,6 @@ tasks.register("capeSyncPayloadSmoke", JavaExec) {
mainClass = "fr.koka99cab.sanctuary26.sanctuary.network.CapeSyncPayloadSmoke"
}
tasks.register("chickenArmorSmoke", JavaExec) {
group = "verification"
description = "Checks that only adult unarmored chickens accept armadillo-scute armor."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryChickenArmorSmoke"
}
tasks.register("inventorySortSmoke", JavaExec) {
group = "verification"
description = "Checks that storage stacks merge and sort while the hotbar row stays out of the sort."
@@ -164,14 +156,6 @@ tasks.register("notchVictoryStateSmoke", JavaExec) {
mainClass = "fr.koka99cab.sanctuary26.sanctuary.data.NotchVictoryStateSmoke"
}
tasks.register("backroomModelSmoke", JavaExec) {
group = "verification"
description = "Checks bounded Backroom entry data, personal loot priority and composed architecture zones."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.sanctuary.backroom.BackroomModelSmoke"
}
tasks.register("verifyBlackMarket") {
group = "verification"
description = "Checks the physical sign market, flash Shop, escrow ledger and mailbox delivery."
@@ -207,7 +191,7 @@ tasks.register("verifyBlackMarket") {
def questData = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardData.java").text
def quests = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardService.java").text
def inventoryView = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/inventory/SanctuaryPlayerInventoryView.java").text
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.130"
|| !data.contains('SAVE_KEY = "black_market"') || !data.contains("DATA_VERSION = 2")
|| !data.contains("ItemStack.CODEC.fieldOf(\"item\")") || !data.contains("replaceListing")
|| !service.contains("LISTING_FEE_RUBIES = 1") || !service.contains("COMMISSION_PERCENT = 5")
@@ -1216,13 +1200,13 @@ tasks.register("verifySanctuary") {
}
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (manifest.custom?.sanctuary26?.data_version != 15
if (manifest.custom?.sanctuary26?.data_version != 13
|| manifest.custom?.sanctuary26?.network_protocol != 24
|| manifest.depends?.jei != ">=30.24.0.176"
|| manifest.depends?."fabric-api" != ">=0.155.0+26.2"
|| !manifest.entrypoints?.client?.contains("fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryClient")
|| !manifest.mixins?.contains("sanctuary.mixins.json")) {
throw new GradleException("Sanctuary must expose data v14, protocol 24, JEI and the client entrypoint")
throw new GradleException("Sanctuary must expose data v13, protocol 24, JEI and the client entrypoint")
}
def capeMigration = new JsonSlurper().parse(rootProject.file(
"pack/migrations/26.2.0-alpha.155-cape-equipment-to-alpha.156.json"))
@@ -1707,8 +1691,8 @@ tasks.register("verifySanctuary") {
def worldgenRoot = file("src/main/resources/data/sanctuary/worldgen")
def worldgenCounts = [
density_function: 12, biome: 7, configured_feature: 14, configured_carver: 1,
placed_feature: 20, noise_settings: 4, world_preset: 2
density_function: 12, biome: 6, configured_feature: 13, configured_carver: 1,
placed_feature: 19, noise_settings: 3, world_preset: 2
]
worldgenCounts.each { type, expected ->
def actual = fileTree(new File(worldgenRoot, type.toString())).matching { include "**/*.json" }.files.size()
@@ -2923,7 +2907,7 @@ tasks.register("verifyShopCurrencyRowsRelease") {
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/BlackMarketService.java").text
def shop = file(
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopService.java").text
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.130"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.117"
|| migration.shop_currency_inventory?.all_unlocked_rows_counted != true
|| migration.shop_currency_inventory?.overflow_rows_counted != true
@@ -3089,7 +3073,7 @@ tasks.register("verifyProgressivePauseMenu") {
"pack/migrations/26.2.0-alpha.190-progressive-pause-menu-to-alpha.191.json"))
def pauseMenu = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/PauseScreenMixin.java").text
def costs = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/progression/SanctuaryProgressionCosts.java").text
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.130"
|| migration.target?.pack_version != "26.2.0-alpha.191"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.109"
|| migration.pause_menu?.progression_full_width != true
@@ -3127,7 +3111,7 @@ tasks.register("verifyShopPricingLayout") {
"pack/migrations/26.2.0-alpha.192-shop-pricing-layout-to-alpha.193.json"))
def screen = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryShopScreen.java").text
def progression = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopProgression.java").text
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.130"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.110"
|| migration.shop_layout?.locked_offer_buttons != 6
|| migration.shop_layout?.button_slots != [4, 5, 6, 7, 8, 9]
@@ -3158,7 +3142,7 @@ tasks.register("verifyShopCsvBossGates") {
"pack/migrations/26.2.0-alpha.194-shop-csv-boss-gates-to-alpha.195.json"))
def provider = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopCatalogProvider.java").text
def catalog = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopOfferCatalog.java").text
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.130"
|| migration.target?.pack_version != "26.2.0-alpha.195"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.111"
|| migration.compatibility?.sanctuary_data_version != 11
@@ -3189,7 +3173,7 @@ tasks.register("verifyDeliveryBoxOrientation") {
def blockstate = new JsonSlurper().parse(
file("src/main/resources/assets/sanctuary/blockstates/delivery_box.json"))
def expectedVariants = ["facing=north", "facing=east", "facing=south", "facing=west"].toSet()
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.130"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.112"
|| migration.mailbox?.id != "sanctuary:delivery_box"
|| migration.mailbox?.horizontal_facing != true
@@ -3217,7 +3201,7 @@ tasks.register("verifyInventoryShiftClick") {
def mixin = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/AbstractContainerMenuMixin.java").text
def screen = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/AbstractContainerScreenMixin.java").text
def gui = file("INVENTORY_GUI.md").text
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.130"
|| !transfer.contains("shouldAppendOverflow")
|| !transfer.contains("isVanillaStorageRange")
|| !transfer.contains("hotbarStart")
@@ -3263,7 +3247,7 @@ tasks.register("verifyQuestRequests") {
new JsonSlurper().parse(file("src/main/resources/data/sanctuary/loot_table/blocks/${path}.json"))
.pools[0].entries[0].name
}
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.130"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.113"
|| migration.quest_requests?.save_key != "quest_requests"
|| migration.quest_requests?.data_version != 1
@@ -3275,8 +3259,6 @@ tasks.register("verifyQuestRequests") {
|| !data.contains('SAVE_KEY = "quest_requests"') || !data.contains("DATA_VERSION = 3")
|| !service.contains("PlayerBlockBreakEvents.BEFORE")
|| !service.contains("player.blockPosition().distSqr(payload.pos()) > 64.0D")
|| !service.contains('"sign.sanctuary.quest_request.completed_by"')
|| !service.contains('"message.sanctuary.quest_request.details.completed_by"')
|| !network.contains("PROTOCOL_VERSION = 24")
|| !network.contains("QuestRequestSubmitPayload.ID")
|| !network.contains("QuestRequestAcceptPayload.ID")
@@ -3286,8 +3268,6 @@ tasks.register("verifyQuestRequests") {
!values.containsKey("block.sanctuary.quest_request_sign")
|| !values.containsKey("screen.sanctuary.quest_request.title")
|| !values.containsKey("message.sanctuary.quest_request.claimed")
|| !values.containsKey("message.sanctuary.quest_request.details.completed_by")
|| !values.containsKey("sign.sanctuary.quest_request.completed_by")
}) {
throw new GradleException("Sanctuary alpha.113 quest-request contract is incomplete")
}
@@ -3325,7 +3305,7 @@ tasks.register("verifyQuestRequestContracts") {
def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale ->
[(locale): new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${locale}.json"))]
}
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.130"
|| migration.source?.pack_version != "26.2.0-alpha.198"
|| migration.source?.modules?.sanctuary != "0.0.0-alpha.114"
|| migration.target?.pack_version != "26.2.0-alpha.199"
@@ -3394,7 +3374,7 @@ tasks.register("verifyQuestRequestEscrow") {
def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale ->
[(locale): new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${locale}.json"))]
}
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.130"
|| migration.source?.pack_version != "26.2.0-alpha.200"
|| migration.source?.modules?.sanctuary != "0.0.0-alpha.115"
|| migration.target?.pack_version != "26.2.0-alpha.201"
@@ -3473,7 +3453,7 @@ tasks.register("verifyCraftableBannerCape") {
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.130"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.130"
|| migration.white_cape?.crafting_material_count != 6
|| migration.white_cape?.maximum_pattern_layers != 6
@@ -3501,52 +3481,6 @@ tasks.named("verifySanctuary") {
dependsOn tasks.named("verifyCraftableBannerCape")
}
tasks.register("verifyFinalSanctuaryTickets") {
group = "verification"
description = "Checks the shield-mapped banner cape and armadillo-scute chicken armor contracts."
inputs.files(
file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryBannerCapeLayer.java"),
file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryChickenArmorLayer.java"),
file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryChickenArmor.java"),
file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/AnimalChickenArmorMixin.java"),
file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/LivingEntityChickenArmorMixin.java"),
file("src/main/resources/sanctuary.mixins.json"))
inputs.files(fileTree("src/main/resources/assets/sanctuary/lang") { include "*.json" })
doLast {
def capeRenderer = file(
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryBannerCapeLayer.java").text
def armorLayer = file(
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryChickenArmorLayer.java").text
def armorRules = file(
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryChickenArmor.java").text
def armorDamage = file(
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/LivingEntityChickenArmorMixin.java").text
def mixins = new JsonSlurper().parse(file("src/main/resources/sanctuary.mixins.json"))
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.137"
|| !capeRenderer.contains("SHIELD_PATTERN_X_SCALE = 1.2F")
|| !capeRenderer.contains("SHIELD_PATTERN_Y_SCALE = 1.375F")
|| !capeRenderer.contains("model, state, false, appearance.baseColor()")
|| !armorLayer.contains("EquipmentClientInfo.LayerType.WOLF_BODY")
|| !armorRules.contains("held.is(Items.WOLF_ARMOR)")
|| !armorRules.contains("DamageTypeTags.BYPASSES_WOLF_ARMOR")
|| !armorDamage.contains("armor.hurtAndBreak")
|| !mixins.mixins.contains("AnimalChickenArmorMixin")
|| !mixins.mixins.contains("LivingEntityChickenArmorMixin")
|| !mixins.client.contains("client.ChickenRendererMixin")
|| !mixins.client.contains("client.ChickenRenderStateMixin")
|| locales.any { !it.containsKey("tooltip.sanctuary.chicken_armor") }) {
throw new GradleException("Sanctuary alpha.136 final-ticket contract is incomplete")
}
}
}
tasks.named("verifySanctuary") {
dependsOn tasks.named("verifyFinalSanctuaryTickets")
}
tasks.register("verifyAlphaIndev") {
group = "verification"
description = "Checks the alpha.122 finite Indev world, migration and attribution contract."
@@ -3574,7 +3508,7 @@ tasks.register("verifyAlphaIndev") {
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.130"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.122"
|| migration.alpha_world?.dimension != "sanctuary:alpha"
|| migration.alpha_world?.legacy_dimension != "portals:alpha"
@@ -3596,91 +3530,7 @@ tasks.register("verifyAlphaIndev") {
}
}
tasks.register("verifyBackroom") {
group = "verification"
description = "Checks Backroom dimension, migration, Mailbox boundary, loss registry and translations."
inputs.file(file("BACKROOM.md"))
inputs.file(rootProject.file("pack/migrations/sanctuary-backroom-data-v14.json"))
inputs.files(fileTree("src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom"))
inputs.files(fileTree("src/main/resources/data/sanctuary") { include "**/*backroom*.json" })
inputs.files(fileTree("src/main/resources/assets/sanctuary/lang") { include "*.json" })
inputs.files(
file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java"),
file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/DeliveryBoxBlockEntity.java"),
file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/feature/BackroomArchitectureFeature.java"),
file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/feature/BackroomLayout.java"),
file("src/main/resources/data/minecraft/worldgen/world_preset/normal.json"),
file("src/main/resources/data/sanctuary/worldgen/world_preset/overworld.json"),
file("src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json"))
doLast {
def migration = new JsonSlurper().parse(rootProject.file("pack/migrations/sanctuary-backroom-data-v14.json"))
def dimensionType = new JsonSlurper().parse(file("src/main/resources/data/sanctuary/dimension_type/backroom.json"))
def noiseSettings = new JsonSlurper().parse(file("src/main/resources/data/sanctuary/worldgen/noise_settings/backroom.json"))
def biome = new JsonSlurper().parse(file("src/main/resources/data/sanctuary/worldgen/biome/backroom.json"))
def presets = [
file("src/main/resources/data/minecraft/worldgen/world_preset/normal.json"),
file("src/main/resources/data/sanctuary/worldgen/world_preset/overworld.json"),
file("src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json")
].collect { new JsonSlurper().parse(it) }
def data = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomData.java").text
def inventory = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomInventory.java").text
def service = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomService.java").text
def chest = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomChestService.java").text
def mailbox = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/DeliveryBoxBlockEntity.java").text
def architecture = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/feature/BackroomArchitectureFeature.java").text
def layout = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/feature/BackroomLayout.java").text
def materialApi = file(
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java").text
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.137"
|| migration.source_data_version != 13 || migration.target_data_version != 14
|| migration.integration_modules?.anotherworld?.source != "0.0.0-alpha.56"
|| migration.integration_modules?.anotherworld?.target != "0.0.0-alpha.57"
|| migration.compatibility?.network_protocol_changed != false
|| presets.any { it.dimensions?.get("sanctuary:backroom") == null }
|| dimensionType.attributes?.get("minecraft:gameplay/bed_rule")?.can_set_spawn != "never"
|| dimensionType.attributes?.get("minecraft:gameplay/bed_rule")?.can_sleep != "never"
|| dimensionType.min_y != -64 || dimensionType.height != 448 || dimensionType.logical_height != 448
|| dimensionType.ambient_light != 0.0 || dimensionType.has_ceiling != true
|| dimensionType.attributes?.get("minecraft:visual/fog_color") != "#000000"
|| noiseSettings.noise?.min_y != -64 || noiseSettings.noise?.height != 448
|| noiseSettings.disable_mob_generation != true
|| biome.creature_spawn_probability != 0.0 || !biome.spawners?.monster?.isEmpty()
|| !data.contains("MAX_LOST_ITEMS = 16_384") || !data.contains("MAX_CHEST_STATES = 65_536")
|| !data.contains("PERSONAL_RESERVATION_TICKS = 24_000L")
|| !data.contains("indexOfLoss(marker)") || !data.contains("BACKROOM_LOSS_COMPONENT")
|| !inventory.contains("companionEgg") || !inventory.contains("equippedCape")
|| !service.contains("ARRIVAL_RADIUS = 2_000") || !service.contains("insertTransaction")
|| !service.contains("AUTHORIZED_TRANSITIONS")
|| !service.contains("level -> !isBackroom(level)")
|| !service.contains("MobCategory.MONSTER") || !service.contains("Blocks.TORCH")
|| !mailbox.contains("MAX_PROCESSED_TRANSACTIONS = 128")
|| !chest.contains("REFRESH_TICKS = 12_000L") || !chest.contains("ordinal == 3")
|| !architecture.contains("PLAYABLE_RADIUS = 2_000")
|| !architecture.contains("BackroomMaterialApi.anomalyMaterial()")
|| !architecture.contains("placeLootLandmark")
|| !layout.contains("WORLD_MIN_Y = -64") || !layout.contains("WORLD_MAX_Y_EXCLUSIVE = 384")
|| !layout.contains("LEVEL_0(295, 383)") || !layout.contains("LEVEL_4(-64, 27)")
|| !layout.contains("mazeConnected") || !layout.contains("RoomCorner")
|| !layout.contains("LOOT_CHEST_CELL_SIZE_CHUNKS = 3") || !layout.contains("lootChestChunk")
|| !layout.contains("EXIT_BED_CELL_SIZE_CHUNKS = 3")
|| !layout.contains("exitBedChunk") || !layout.contains("exitBedFloor")
|| !materialApi.contains("registerAnomalyMaterial")
|| locales.any { !it.containsKey("dimension.sanctuary.backroom")
|| !it.containsKey("message.sanctuary.backroom.bed_entry_only")
|| !it.containsKey("message.sanctuary.backroom.registry_busy")
|| !it.containsKey("message.sanctuary.backroom.mailbox_full")
|| !it.containsKey("message.sanctuary.backroom.exited_with_losses") }) {
throw new GradleException("Sanctuary alpha.134 Backroom contract is incomplete")
}
}
}
tasks.named("check") {
dependsOn tasks.named("backroomModelSmoke")
dependsOn tasks.named("verifyBackroom")
dependsOn tasks.named("notchVictoryStateSmoke")
dependsOn tasks.named("verifySanctuary")
dependsOn tasks.named("verifyDeliveryBoxOrientation")
@@ -3696,7 +3546,6 @@ tasks.named("check") {
dependsOn tasks.named("alphaIndevGeneratorSmoke")
dependsOn tasks.named("capeJoinSyncSmoke")
dependsOn tasks.named("capeSyncPayloadSmoke")
dependsOn tasks.named("chickenArmorSmoke")
dependsOn tasks.named("inventorySortSmoke")
dependsOn tasks.named("entityPolicySmoke")
dependsOn tasks.named("verifyAlphaIndev")
@@ -2,7 +2,6 @@ package fr.koka99cab.sanctuary26.sanctuary;
import fr.koka99cab.sanctuary26.sanctuary.api.atlas.SanctuaryAtlasLandmarks;
import fr.koka99cab.sanctuary26.sanctuary.api.server.SanctuaryServerIdentity;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.SanctuaryRankingApi;
import fr.koka99cab.sanctuary26.sanctuary.network.SanctuaryNetworking;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryBulkActions;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryInventoryCapacity;
@@ -27,7 +26,6 @@ import fr.koka99cab.sanctuary26.sanctuary.shop.ShopService;
import fr.koka99cab.sanctuary26.sanctuary.shop.BlackMarketService;
import fr.koka99cab.sanctuary26.sanctuary.shop.QuestBoardService;
import fr.koka99cab.sanctuary26.sanctuary.shop.QuestRequestService;
import fr.koka99cab.sanctuary26.sanctuary.backroom.BackroomService;
import net.fabricmc.api.ModInitializer;
import net.minecraft.resources.Identifier;
import org.slf4j.Logger;
@@ -36,7 +34,7 @@ import org.slf4j.LoggerFactory;
public final class SanctuaryMod implements ModInitializer {
public static final String MOD_ID = "sanctuary";
public static final String DISPLAY_NAME = "Sanctuary";
public static final int CURRENT_DATA_VERSION = 15;
public static final int CURRENT_DATA_VERSION = 13;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public SanctuaryMod() {
@@ -49,14 +47,12 @@ public final class SanctuaryMod implements ModInitializer {
@Override
public void onInitialize() {
SanctuaryServerIdentity.register();
SanctuaryRankingApi.initialize();
SanctuaryDimensions.initialize();
SanctuaryDensityFunctions.register();
SanctuaryFeatures.register();
SanctuarySpawn.register();
SanctuaryWorldProfileData.register();
SanctuaryRegistries.initialize();
BackroomService.initialize();
SanctuaryBulkActions.initialize();
SanctuaryInventoryCapacity.initialize();
SanctuaryHotbarRows.initialize();
@@ -1,20 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.api.backroom;
import java.util.Objects;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
/** Public, one-way integration point for rare materials owned by other modules. */
public final class BackroomMaterialApi {
private static Block anomalyMaterial = Blocks.WARPED_WART_BLOCK;
private BackroomMaterialApi() {}
public static void registerAnomalyMaterial(Block material) {
anomalyMaterial = Objects.requireNonNull(material, "Backroom anomaly material");
}
public static Block anomalyMaterial() {
return anomalyMaterial;
}
}
@@ -1,22 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import java.util.Objects;
import net.minecraft.resources.Identifier;
/** Stable public description registered by gameplay modules, independent from SanctuaryStat ranks. */
public record RankingMetric(Identifier id, String translationKey, RankingUnit unit) {
public static final int MAX_IDENTIFIER_LENGTH = 256;
public static final int MAX_TRANSLATION_KEY_LENGTH = 128;
public RankingMetric {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(unit, "unit");
if (id.toString().length() > MAX_IDENTIFIER_LENGTH) {
throw new IllegalArgumentException("Ranking identifier must contain at most 256 characters");
}
translationKey = translationKey == null ? "" : translationKey.trim();
if (translationKey.isEmpty() || translationKey.length() > MAX_TRANSLATION_KEY_LENGTH) {
throw new IllegalArgumentException("Ranking translation key must contain 1-128 characters");
}
}
}
@@ -1,44 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import java.util.List;
import java.util.UUID;
import net.minecraft.resources.Identifier;
/** Server snapshot intentionally bounded to one visible metric and three podium entries. */
public record RankingSnapshot(RankingMetric metric, List<Entry> podium, Placement owner) {
public static final int MAX_PODIUM_SIZE = 3;
public RankingSnapshot {
podium = podium == null ? List.of() : List.copyOf(podium.stream().limit(MAX_PODIUM_SIZE).toList());
owner = owner == null ? Placement.unranked() : owner;
}
public static RankingSnapshot empty(RankingMetric metric) {
return new RankingSnapshot(metric, List.of(), Placement.unranked());
}
public record Entry(UUID playerId, String playerName, long value, int rank) {
public Entry {
playerName = sanitizeName(playerName);
value = Math.max(0L, value);
rank = Math.max(0, rank);
}
}
public record Placement(long value, int rank) {
public Placement {
value = Math.max(0L, value);
rank = Math.max(0, rank);
}
public static Placement unranked() {
return new Placement(0L, 0);
}
}
private static String sanitizeName(String value) {
if (value == null || value.isBlank()) return "?";
String clean = value.strip();
return clean.length() > 32 ? clean.substring(0, 32) : clean;
}
}
@@ -1,49 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
/** Public, bounded display formats accepted by server ranking metrics. */
public enum RankingUnit {
COUNT("count"),
TICKS("ticks"),
CENTIMETERS("centimeters");
private final String key;
RankingUnit(String key) {
this.key = key;
}
public String key() {
return key;
}
public static RankingUnit byKey(String key) {
for (RankingUnit unit : values()) {
if (unit.key.equals(key)) return unit;
}
return COUNT;
}
/** Compact, locale-neutral representation suitable for signs and chat snapshots. */
public String format(long rawValue) {
long value = Math.max(0L, rawValue);
return switch (this) {
case TICKS -> formatDuration(value / 20L);
case CENTIMETERS -> value >= 100_000L ? (value / 100_000L) + " km" : (value / 100L) + " m";
case COUNT -> compact(value);
};
}
private static String formatDuration(long seconds) {
long hours = seconds / 3_600L;
long minutes = seconds % 3_600L / 60L;
if (hours > 0L) return hours + " h " + minutes + " min";
return minutes + " min";
}
private static String compact(long value) {
if (value >= 1_000_000_000L) return value / 1_000_000_000L + "G";
if (value >= 1_000_000L) return value / 1_000_000L + "M";
if (value >= 1_000L) return value / 1_000L + "K";
return Long.toString(value);
}
}
@@ -1,102 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
/**
* Public server-authoritative ranking API. Gameplay modules may register stable metric descriptions and
* increment them for real survival/adventure players; Red-Stoner only consumes bounded snapshots.
*/
public final class SanctuaryRankingApi {
private static final Map<Identifier, RankingMetric> METRICS = new ConcurrentHashMap<>();
private static final RankingMetric DEFAULT_METRIC = VanillaRankingMetric.descriptor(
VanillaRankingMetric.playTimeId());
private static boolean initialized;
private SanctuaryRankingApi() {}
public static void initialize() {
if (initialized) return;
initialized = true;
register(DEFAULT_METRIC);
ServerLifecycleEvents.SERVER_STARTED.register(server ->
SanctuaryRankingData.get(server).importVanillaStats(server));
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
SanctuaryRankingData data = SanctuaryRankingData.get(server);
data.updateName(handler.player.getUUID(), handler.player.getGameProfile().name());
synchronizeObserved(server, METRICS.keySet());
});
}
public static RankingMetric register(RankingMetric metric) {
RankingMetric previous = METRICS.putIfAbsent(metric.id(), metric);
if (previous != null && !previous.equals(metric)) {
throw new IllegalStateException("Ranking metric already registered with another contract: " + metric.id());
}
return previous == null ? metric : previous;
}
public static RankingMetric defaultMetric() {
return DEFAULT_METRIC;
}
public static Optional<RankingMetric> metric(Identifier id) {
RankingMetric registered = METRICS.get(id);
if (registered != null) return Optional.of(registered);
return VanillaRankingMetric.parse(id).map(source -> {
RankingMetric descriptor = VanillaRankingMetric.descriptor(id);
METRICS.putIfAbsent(id, descriptor);
return descriptor;
});
}
public static List<RankingMetric> itemMetrics(ItemStack icon) {
List<RankingMetric> metrics = VanillaRankingMetric.itemMetrics(icon);
metrics.forEach(SanctuaryRankingApi::register);
return metrics;
}
public static boolean eligible(ServerPlayer player) {
return isRealOnlinePlayer(player) && !player.isCreative() && !player.isSpectator();
}
public static void increment(ServerPlayer player, Identifier metricId, long amount) {
if (!eligible(player) || amount <= 0L || metric(metricId).isEmpty()) return;
SanctuaryRankingData.get(player.level().getServer()).increment(player.getUUID(),
player.getGameProfile().name(), metricId, amount);
}
public static RankingSnapshot snapshot(MinecraftServer server, Identifier metricId, UUID ownerId) {
RankingMetric metric = metric(metricId).orElse(DEFAULT_METRIC);
synchronizeObserved(server, List.of(metric.id()));
return SanctuaryRankingData.get(server).snapshot(metric, ownerId);
}
public static void synchronizeObserved(MinecraftServer server, Collection<Identifier> metricIds) {
if (server == null || metricIds == null || metricIds.isEmpty()) return;
SanctuaryRankingData data = SanctuaryRankingData.get(server);
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
for (Identifier metricId : metricIds) {
VanillaRankingMetric.Source source = VanillaRankingMetric.parse(metricId).orElse(null);
if (source == null) continue;
data.observeVanilla(player.getUUID(), player.getGameProfile().name(), metricId,
VanillaRankingMetric.read(player, source), eligible(player));
}
}
}
private static boolean isRealOnlinePlayer(ServerPlayer player) {
return player != null && player.getClass() == ServerPlayer.class
&& player.level().getServer().getPlayerList().getPlayer(player.getUUID()) == player;
}
}
@@ -1,292 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Stream;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
import net.minecraft.world.level.storage.LevelResource;
/** Versioned UUID/metric index; Red-Stoner never owns or duplicates these scores. */
final class SanctuaryRankingData extends SavedData {
static final String SAVE_KEY = "rankings";
static final int DATA_VERSION = 1;
static final long MAX_VALUE = Long.MAX_VALUE / 4L;
private static final int MAX_METRICS_PER_PLAYER = 16_384;
private static final Codec<Score> SCORE_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.LONG.fieldOf("value").forGetter(Score::value),
Codec.LONG.fieldOf("reached_order").forGetter(Score::reachedOrder)
).apply(instance, Score::new));
private static final Codec<Profile> PROFILE_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.optionalFieldOf("last_name", "?").forGetter(Profile::lastName),
Codec.unboundedMap(Codec.STRING, SCORE_CODEC).optionalFieldOf("metrics", Map.of())
.forGetter(Profile::metrics),
Codec.unboundedMap(Codec.STRING, Codec.LONG).optionalFieldOf("vanilla_observed", Map.of())
.forGetter(Profile::vanillaObserved)
).apply(instance, Profile::new));
private static final Codec<SanctuaryRankingData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.INT.optionalFieldOf("data_version", DATA_VERSION).forGetter(data -> data.dataVersion),
Codec.BOOL.optionalFieldOf("vanilla_stats_imported", false).forGetter(data -> data.vanillaStatsImported),
Codec.LONG.optionalFieldOf("next_reached_order", 1L).forGetter(data -> data.nextReachedOrder),
Codec.unboundedMap(Codec.STRING, PROFILE_CODEC).optionalFieldOf("players", Map.of())
.forGetter(data -> data.players)
).apply(instance, SanctuaryRankingData::new));
private static final SavedDataType<SanctuaryRankingData> TYPE = new SavedDataType<>(
SanctuaryMod.id(SAVE_KEY), SanctuaryRankingData::new, CODEC, null);
private int dataVersion;
private boolean vanillaStatsImported;
private long nextReachedOrder;
private final Map<String, Profile> players;
SanctuaryRankingData() {
this(DATA_VERSION, false, 1L, Map.of());
}
SanctuaryRankingData(int dataVersion, boolean vanillaStatsImported, long nextReachedOrder,
Map<String, Profile> players) {
this.dataVersion = dataVersion;
this.vanillaStatsImported = vanillaStatsImported;
this.nextReachedOrder = Math.max(1L, nextReachedOrder);
this.players = new HashMap<>();
players.forEach((player, profile) -> {
try {
UUID.fromString(player);
this.players.put(player, sanitize(profile));
} catch (IllegalArgumentException ignored) {
// A malformed external key is ignored rather than poisoning the complete save.
}
});
}
static SanctuaryRankingData get(MinecraftServer server) {
ServerLevel overworld = server.getLevel(Level.OVERWORLD);
if (overworld == null) throw new IllegalStateException("Overworld unavailable for ranking data");
SanctuaryRankingData data = overworld.getDataStorage().computeIfAbsent(TYPE);
if (data.dataVersion < DATA_VERSION) {
data.dataVersion = DATA_VERSION;
data.setDirty();
} else if (data.dataVersion > DATA_VERSION) {
SanctuaryMod.LOGGER.warn("[{}] Ranking data v{} is newer than supported v{}; writes are disabled.",
SanctuaryMod.DISPLAY_NAME, data.dataVersion, DATA_VERSION);
}
return data;
}
boolean writable() {
return dataVersion <= DATA_VERSION;
}
void updateName(UUID playerId, String name) {
if (!writable()) return;
String key = playerId.toString();
Profile current = players.getOrDefault(key, new Profile(cleanName(name), Map.of(), Map.of()));
String clean = cleanName(name);
if (current.lastName().equals(clean)) return;
players.put(key, new Profile(clean, current.metrics(), current.vanillaObserved()));
setDirty();
}
void recordValue(UUID playerId, String name, Identifier metricId, long rawValue) {
if (!writable() || rawValue < 0L) return;
String key = playerId.toString();
Profile current = players.getOrDefault(key, new Profile(cleanName(name), Map.of(), Map.of()));
Map<String, Score> metrics = new HashMap<>(current.metrics());
String metricKey = metricId.toString();
Score previous = metrics.get(metricKey);
long value = Math.min(MAX_VALUE, rawValue);
if (previous != null && previous.value() >= value) {
updateName(playerId, name);
return;
}
if (previous == null && metrics.size() >= MAX_METRICS_PER_PLAYER) return;
metrics.put(metricKey, new Score(value, takeReachedOrder()));
players.put(key, new Profile(cleanName(name), metrics, current.vanillaObserved()));
setDirty();
}
void observeVanilla(UUID playerId, String name, Identifier metricId, long rawValue, boolean credited) {
if (!writable() || rawValue < 0L) return;
String key = playerId.toString();
Profile current = players.getOrDefault(key, new Profile(cleanName(name), Map.of(), Map.of()));
Map<String, Long> observed = new HashMap<>(current.vanillaObserved());
String metricKey = metricId.toString();
Long previous = observed.get(metricKey);
if (previous == null && observed.size() >= MAX_METRICS_PER_PLAYER) return;
long value = Math.min(MAX_VALUE, rawValue);
if (previous == null || previous.longValue() != value || !current.lastName().equals(cleanName(name))) {
observed.put(metricKey, value);
players.put(key, new Profile(cleanName(name), current.metrics(), observed));
setDirty();
}
long creditedDelta = previous == null ? value : Math.max(0L, value - previous);
if (credited && creditedDelta > 0L) {
increment(playerId, name, metricId, creditedDelta);
}
}
void increment(UUID playerId, String name, Identifier metricId, long amount) {
if (!writable() || amount <= 0L) return;
long current = score(playerId, metricId);
long value = current >= MAX_VALUE - amount ? MAX_VALUE : current + amount;
recordValue(playerId, name, metricId, value);
}
long score(UUID playerId, Identifier metricId) {
Profile profile = players.get(playerId.toString());
Score score = profile == null ? null : profile.metrics().get(metricId.toString());
return score == null ? 0L : score.value();
}
RankingSnapshot snapshot(RankingMetric metric, UUID ownerId) {
List<Candidate> candidates = new ArrayList<>();
players.forEach((player, profile) -> {
Score score = profile.metrics().get(metric.id().toString());
if (score == null || score.value() <= 0L) return;
try {
candidates.add(new Candidate(UUID.fromString(player), profile.lastName(), score));
} catch (IllegalArgumentException ignored) {
// Already filtered during load, retained as defensive parsing.
}
});
candidates.sort(Comparator.comparingLong((Candidate candidate) -> candidate.score().value()).reversed()
.thenComparingLong(candidate -> candidate.score().reachedOrder())
.thenComparing(candidate -> candidate.playerId().toString()));
List<RankingSnapshot.Entry> podium = new ArrayList<>();
RankingSnapshot.Placement owner = RankingSnapshot.Placement.unranked();
long previousValue = -1L;
int rank = 0;
for (int index = 0; index < candidates.size(); index++) {
Candidate candidate = candidates.get(index);
if (candidate.score().value() != previousValue) {
rank = index + 1;
previousValue = candidate.score().value();
}
if (podium.size() < RankingSnapshot.MAX_PODIUM_SIZE) {
podium.add(new RankingSnapshot.Entry(candidate.playerId(), candidate.name(),
candidate.score().value(), rank));
}
if (candidate.playerId().equals(ownerId)) {
owner = new RankingSnapshot.Placement(candidate.score().value(), rank);
}
}
return new RankingSnapshot(metric, podium, owner);
}
void importVanillaStats(MinecraftServer server) {
if (!writable() || vanillaStatsImported) return;
Path statsDirectory = server.getWorldPath(LevelResource.PLAYER_STATS_DIR);
if (Files.isDirectory(statsDirectory)) {
try (Stream<Path> files = Files.list(statsDirectory)) {
files.filter(path -> path.getFileName().toString().endsWith(".json"))
.sorted().forEach(this::importVanillaFile);
} catch (IOException exception) {
SanctuaryMod.LOGGER.warn("[{}] Could not import existing vanilla statistics.",
SanctuaryMod.DISPLAY_NAME, exception);
return;
}
}
vanillaStatsImported = true;
setDirty();
}
void importVanillaFile(Path file) {
String fileName = file.getFileName().toString();
String idText = fileName.substring(0, fileName.length() - ".json".length());
UUID playerId;
try {
playerId = UUID.fromString(idText);
} catch (IllegalArgumentException ignored) {
return;
}
try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
JsonObject root = JsonParser.parseReader(reader).getAsJsonObject();
JsonObject stats = root.has("stats") && root.get("stats").isJsonObject()
? root.getAsJsonObject("stats") : new JsonObject();
for (Map.Entry<String, JsonElement> typeEntry : stats.entrySet()) {
Identifier type = Identifier.tryParse(typeEntry.getKey());
if (type == null || !typeEntry.getValue().isJsonObject()) continue;
for (Map.Entry<String, JsonElement> valueEntry : typeEntry.getValue().getAsJsonObject().entrySet()) {
Identifier target = Identifier.tryParse(valueEntry.getKey());
if (target == null || !valueEntry.getValue().isJsonPrimitive()
|| !valueEntry.getValue().getAsJsonPrimitive().isNumber()) continue;
Identifier metricId = VanillaRankingMetric.id(type, target);
long value = Math.max(0L, valueEntry.getValue().getAsLong());
recordValue(playerId, shortFallbackName(playerId), metricId, value);
observeVanilla(playerId, shortFallbackName(playerId), metricId, value, false);
}
}
} catch (RuntimeException | IOException exception) {
SanctuaryMod.LOGGER.warn("[{}] Ignoring unreadable vanilla statistic file {}.",
SanctuaryMod.DISPLAY_NAME, file.getFileName(), exception);
}
}
private long takeReachedOrder() {
long current = nextReachedOrder;
if (nextReachedOrder < Long.MAX_VALUE) nextReachedOrder++;
return current;
}
private static Profile sanitize(Profile profile) {
Map<String, Score> metrics = new HashMap<>();
profile.metrics().entrySet().stream().limit(MAX_METRICS_PER_PLAYER).forEach(entry -> {
if (Identifier.tryParse(entry.getKey()) != null) metrics.put(entry.getKey(), entry.getValue());
});
Map<String, Long> observed = new HashMap<>();
profile.vanillaObserved().entrySet().stream().limit(MAX_METRICS_PER_PLAYER).forEach(entry -> {
if (Identifier.tryParse(entry.getKey()) != null) {
observed.put(entry.getKey(), Math.clamp(entry.getValue(), 0L, MAX_VALUE));
}
});
return new Profile(cleanName(profile.lastName()), metrics, observed);
}
private static String cleanName(String value) {
if (value == null || value.isBlank()) return "?";
String clean = value.strip();
return clean.length() > 32 ? clean.substring(0, 32) : clean;
}
private static String shortFallbackName(UUID playerId) {
return playerId.toString().substring(0, 8);
}
record Score(long value, long reachedOrder) {
Score {
value = Math.clamp(value, 0L, MAX_VALUE);
reachedOrder = Math.max(1L, reachedOrder);
}
}
record Profile(String lastName, Map<String, Score> metrics, Map<String, Long> vanillaObserved) {
Profile {
lastName = cleanName(lastName);
metrics = metrics == null ? Map.of() : Map.copyOf(metrics);
vanillaObserved = vanillaObserved == null ? Map.of() : Map.copyOf(vanillaObserved);
}
}
private record Candidate(UUID playerId, String name, Score score) {}
}
@@ -1,88 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.stats.Stats;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
/** Stable encoding and server reader for persisted vanilla statistic identifiers. */
final class VanillaRankingMetric {
private static final String PREFIX = "vanilla/";
private static final List<String> ITEM_FAMILIES = List.of(
"mined", "crafted", "used", "broken", "picked_up", "dropped");
private VanillaRankingMetric() {}
static Identifier id(Identifier type, Identifier target) {
return SanctuaryMod.id(PREFIX + type.getNamespace() + "/" + type.getPath() + "/"
+ target.getNamespace() + "/" + target.getPath());
}
static Identifier playTimeId() {
return id(Identifier.withDefaultNamespace("custom"), Stats.PLAY_TIME);
}
static RankingMetric descriptor(Identifier id) {
Source source = parse(id).orElse(null);
if (source == null) {
return new RankingMetric(id, "metric.sanctuary.ranking.unknown", RankingUnit.COUNT);
}
boolean playTime = source.type().getPath().equals("custom") && source.target().equals(Stats.PLAY_TIME);
RankingUnit unit = playTime ? RankingUnit.TICKS : RankingUnit.COUNT;
return new RankingMetric(id, "metric.sanctuary.ranking."
+ (playTime ? "play_time" : source.type().getPath()), unit);
}
static List<RankingMetric> itemMetrics(ItemStack icon) {
if (icon == null || icon.isEmpty()) return List.of();
Item item = icon.getItem();
Identifier itemId = BuiltInRegistries.ITEM.getKey(item);
List<RankingMetric> metrics = new ArrayList<>();
for (String family : ITEM_FAMILIES) {
if (family.equals("mined") && !(item instanceof BlockItem)) continue;
Identifier metricId = id(Identifier.withDefaultNamespace(family), itemId);
metrics.add(new RankingMetric(metricId, "metric.sanctuary.ranking." + family, RankingUnit.COUNT));
}
return List.copyOf(metrics);
}
static Optional<Source> parse(Identifier metricId) {
if (!metricId.getNamespace().equals(SanctuaryMod.MOD_ID)
|| !metricId.getPath().startsWith(PREFIX)) return Optional.empty();
String[] parts = metricId.getPath().split("/", 5);
if (parts.length != 5) return Optional.empty();
Identifier type = Identifier.tryParse(parts[1] + ":" + parts[2]);
Identifier target = Identifier.tryParse(parts[3] + ":" + parts[4]);
return type == null || target == null ? Optional.empty() : Optional.of(new Source(type, target));
}
static long read(ServerPlayer player, Source source) {
String family = source.type().getPath();
Identifier target = source.target();
return switch (family) {
case "mined" -> BuiltInRegistries.BLOCK.getOptional(target)
.map(block -> (long) player.getStats().getValue(Stats.BLOCK_MINED, block)).orElse(0L);
case "crafted" -> BuiltInRegistries.ITEM.getOptional(target)
.map(item -> (long) player.getStats().getValue(Stats.ITEM_CRAFTED, item)).orElse(0L);
case "used" -> BuiltInRegistries.ITEM.getOptional(target)
.map(item -> (long) player.getStats().getValue(Stats.ITEM_USED, item)).orElse(0L);
case "broken" -> BuiltInRegistries.ITEM.getOptional(target)
.map(item -> (long) player.getStats().getValue(Stats.ITEM_BROKEN, item)).orElse(0L);
case "picked_up" -> BuiltInRegistries.ITEM.getOptional(target)
.map(item -> (long) player.getStats().getValue(Stats.ITEM_PICKED_UP, item)).orElse(0L);
case "dropped" -> BuiltInRegistries.ITEM.getOptional(target)
.map(item -> (long) player.getStats().getValue(Stats.ITEM_DROPPED, item)).orElse(0L);
case "custom" -> (long) player.getStats().getValue(Stats.CUSTOM, target);
default -> 0L;
};
}
record Source(Identifier type, Identifier target) {}
}
@@ -1,149 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.backroom;
import fr.koka99cab.sanctuary26.sanctuary.registry.SanctuaryRegistries;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.RandomSource;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.ChestBlock;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.ChestType;
/** Renewable loot nodes backed by the non-duplicating real-item registry. */
public final class BackroomChestService {
public static final long REFRESH_TICKS = 12_000L;
private BackroomChestService() {}
public static void open(ChestBlockEntity opened, ServerPlayer player) {
if (!(opened.getLevel() instanceof ServerLevel level) || !BackroomService.isBackroom(level)) return;
ChestGroup group = group(level, opened);
BackroomData data = BackroomData.get(level.getServer());
long cycle = Math.floorDiv(level.getGameTime(), REFRESH_TICKS);
BackroomData.ChestState state = data.chest(group.anchor).orElse(null);
ArrayList<UUID> assigned = state == null ? new ArrayList<>() : new ArrayList<>(state.assignedIds());
boolean refreshed = state == null || state.cycle() != cycle;
if (refreshed) {
data.reconcileChest(group.anchor, markers(group));
group.clear();
assigned.clear();
}
BackroomData.ChestVisit visit = data.visitChest(player.getUUID(), group.anchor);
boolean personalInserted = false;
if (visit.firstVisit() && personalRoll(player.getRandom(), visit.ordinal())) {
BackroomData.MaterializedLoss loss = data.takePersonal(player.getUUID(), group.anchor).orElse(null);
if (loss != null && group.insertLoss(loss.stack())) {
assigned.add(loss.id());
personalInserted = true;
} else if (loss != null) {
data.capture(loss.stack(), player.getUUID(), "chest_full", level.getGameTime());
}
}
if (refreshed) rollGeneralLoot(level, group, data, assigned, cycle);
if (refreshed || personalInserted) data.updateChest(group.anchor, cycle, assigned);
}
public static boolean personalRoll(RandomSource random, int ordinal) {
if (ordinal <= 0) return false;
if (ordinal == 1) return random.nextFloat() < 0.75F;
if (ordinal == 2) return random.nextFloat() < 0.90F;
if (ordinal == 3) return true;
return random.nextFloat() < 0.65F;
}
private static void rollGeneralLoot(ServerLevel level, ChestGroup group, BackroomData data,
List<UUID> assigned, long cycle) {
RandomSource random = RandomSource.create(level.getSeed() ^ group.anchor ^ cycle * 0x9E3779B97F4A7C15L);
int roll = random.nextInt(100);
if (roll < 55) return;
if (roll < 74) {
insert(group, random, Items.DIRT, 4, 20);
if (random.nextBoolean()) insert(group, random, Items.OAK_PLANKS, 2, 10);
return;
}
if (roll < 90) {
insert(group, random, Items.COBBLESTONE, 6, 24);
insert(group, random, random.nextBoolean() ? Items.TORCH : Items.BREAD, 2, 7);
if (random.nextInt(3) == 0) group.insert(new ItemStack(Items.WOODEN_PICKAXE));
return;
}
if (roll < 97) {
data.takeShared(group.anchor, level.getGameTime()).ifPresent(loss -> {
if (group.insertLoss(loss.stack())) assigned.add(loss.id());
else data.capture(loss.stack(), null, "chest_full", level.getGameTime());
});
return;
}
if (roll < 99) {
group.insert(new ItemStack(Items.IRON_PICKAXE));
insert(group, random, Items.ENDER_PEARL, 1, 2);
} else {
group.insert(new ItemStack(Items.DIAMOND));
}
}
private static void insert(ChestGroup group, RandomSource random, Item item, int minimum, int maximum) {
group.insert(new ItemStack(item, minimum + random.nextInt(maximum - minimum + 1)));
}
private static List<UUID> markers(ChestGroup group) {
ArrayList<UUID> result = new ArrayList<>();
for (ChestBlockEntity chest : group.chests) for (int slot = 0; slot < chest.getContainerSize(); slot++) {
UUID marker = chest.getItem(slot).get(SanctuaryRegistries.BACKROOM_LOSS_COMPONENT);
if (marker != null) result.add(marker);
}
return result;
}
private static ChestGroup group(ServerLevel level, ChestBlockEntity opened) {
BlockPos pos = opened.getBlockPos();
BlockState state = opened.getBlockState();
if (!(state.getBlock() instanceof ChestBlock) || state.getValue(ChestBlock.TYPE) == ChestType.SINGLE) {
return new ChestGroup(pos.asLong(), List.of(opened));
}
BlockPos connectedPos = ChestBlock.getConnectedBlockPos(pos, state);
if (!(level.getBlockEntity(connectedPos) instanceof ChestBlockEntity connected)) {
return new ChestGroup(pos.asLong(), List.of(opened));
}
long anchor = Math.min(pos.asLong(), connectedPos.asLong());
return pos.asLong() == anchor
? new ChestGroup(anchor, List.of(opened, connected))
: new ChestGroup(anchor, List.of(connected, opened));
}
private record ChestGroup(long anchor, List<ChestBlockEntity> chests) {
private void clear() {
chests.forEach(chest -> { chest.clearContent(); chest.setChanged(); });
}
private boolean insert(ItemStack offered) {
if (offered == null || offered.isEmpty()) return false;
for (ChestBlockEntity chest : chests) if (insertInto(chest, offered)) return true;
return false;
}
/** Keeps every real loss in the physical anchor chest tracked by the persisted state. */
private boolean insertLoss(ItemStack offered) {
return offered != null && !offered.isEmpty() && insertInto(chests.getFirst(), offered);
}
private static boolean insertInto(ChestBlockEntity chest, ItemStack offered) {
for (int slot = 0; slot < chest.getContainerSize(); slot++) {
if (!chest.getItem(slot).isEmpty()) continue;
chest.setItem(slot, offered.copy());
chest.setChanged();
return true;
}
return false;
}
}
}
@@ -1,316 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.backroom;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import fr.koka99cab.sanctuary26.sanctuary.registry.SanctuaryRegistries;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Persistent, bounded state for Backroom entries and the real-item loss pool. */
public final class BackroomData extends SavedData {
public static final String SAVE_KEY = "backroom";
public static final int DATA_VERSION = 1;
public static final int MAX_PENDING_ENTRIES = 256;
public static final int MAX_ENTRY_SLOTS = 64;
public static final int MAX_LOST_ITEMS = 16_384;
public static final int MAX_CHEST_STATES = 65_536;
public static final int MAX_PLAYER_STATES = 4_096;
public static final int MAX_VISITED_CHESTS = 128;
public static final int MAX_RECENT_ARRIVALS = 8;
public static final long PERSONAL_RESERVATION_TICKS = 24_000L;
public static final String LOST_POOL = "pool";
public static final String LOST_CHEST = "chest";
public static final Codec<EntrySlot> ENTRY_SLOT_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("location").forGetter(EntrySlot::location),
Codec.INT.fieldOf("index").forGetter(EntrySlot::index),
ItemStack.CODEC.fieldOf("item").forGetter(EntrySlot::item)
).apply(instance, EntrySlot::new));
private static final Codec<PendingEntry> PENDING_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("player").forGetter(PendingEntry::player),
Codec.STRING.fieldOf("transaction").forGetter(PendingEntry::transaction),
ENTRY_SLOT_CODEC.listOf(0, MAX_ENTRY_SLOTS).fieldOf("slots").forGetter(PendingEntry::slots)
).apply(instance, PendingEntry::new));
private static final Codec<LostItem> LOST_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("id").forGetter(LostItem::id),
Codec.STRING.optionalFieldOf("owner", "").forGetter(LostItem::owner),
Codec.STRING.fieldOf("cause").forGetter(LostItem::cause),
Codec.LONG.fieldOf("lost_at").forGetter(LostItem::lostAt),
ItemStack.CODEC.fieldOf("item").forGetter(LostItem::item),
Codec.STRING.optionalFieldOf("state", LOST_POOL).forGetter(LostItem::state),
Codec.LONG.optionalFieldOf("chest", Long.MIN_VALUE).forGetter(LostItem::chest)
).apply(instance, LostItem::new));
private static final Codec<ChestState> CHEST_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.LONG.fieldOf("pos").forGetter(ChestState::pos),
Codec.LONG.fieldOf("cycle").forGetter(ChestState::cycle),
Codec.STRING.listOf(0, 54).optionalFieldOf("assigned", List.of()).forGetter(ChestState::assigned)
).apply(instance, ChestState::new));
private static final Codec<PlayerState> PLAYER_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("player").forGetter(PlayerState::player),
Codec.INT.optionalFieldOf("personal_attempts", 0).forGetter(PlayerState::personalAttempts),
Codec.LONG.listOf(0, MAX_VISITED_CHESTS).optionalFieldOf("visited_chests", List.of()).forGetter(PlayerState::visitedChests),
Codec.LONG.listOf(0, MAX_RECENT_ARRIVALS).optionalFieldOf("recent_arrivals", List.of()).forGetter(PlayerState::recentArrivals)
).apply(instance, PlayerState::new));
private static final Codec<BackroomData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.INT.optionalFieldOf("data_version", DATA_VERSION).forGetter(data -> data.dataVersion),
PENDING_CODEC.listOf(0, MAX_PENDING_ENTRIES).optionalFieldOf("pending_entries", List.of()).forGetter(data -> data.pendingEntries),
LOST_CODEC.listOf(0, MAX_LOST_ITEMS).optionalFieldOf("lost_items", List.of()).forGetter(data -> data.lostItems),
CHEST_CODEC.listOf(0, MAX_CHEST_STATES).optionalFieldOf("chests", List.of()).forGetter(data -> data.chests),
PLAYER_CODEC.listOf(0, MAX_PLAYER_STATES).optionalFieldOf("players", List.of()).forGetter(data -> data.players)
).apply(instance, BackroomData::new));
private static final SavedDataType<BackroomData> TYPE = new SavedDataType<>(
SanctuaryMod.id(SAVE_KEY), BackroomData::new, CODEC, null);
private int dataVersion;
private final List<PendingEntry> pendingEntries;
private final List<LostItem> lostItems;
private final List<ChestState> chests;
private final Map<Long, Integer> chestIndices;
private final List<PlayerState> players;
public BackroomData() {
this(DATA_VERSION, List.of(), List.of(), List.of(), List.of());
}
private BackroomData(int dataVersion, List<PendingEntry> pendingEntries, List<LostItem> lostItems,
List<ChestState> chests, List<PlayerState> players) {
this.dataVersion = Math.max(DATA_VERSION, dataVersion);
this.pendingEntries = bounded(pendingEntries, MAX_PENDING_ENTRIES);
this.lostItems = bounded(lostItems, MAX_LOST_ITEMS);
this.chests = bounded(chests, MAX_CHEST_STATES);
this.chestIndices = new HashMap<>();
for (int index = 0; index < this.chests.size(); index++) chestIndices.put(this.chests.get(index).pos, index);
this.players = bounded(players, MAX_PLAYER_STATES);
}
public static BackroomData get(MinecraftServer server) {
ServerLevel overworld = server.getLevel(Level.OVERWORLD);
if (overworld == null) throw new IllegalStateException("Overworld unavailable for Backroom data");
BackroomData data = overworld.getDataStorage().computeIfAbsent(TYPE);
if (data.dataVersion < DATA_VERSION) {
data.dataVersion = DATA_VERSION;
data.setDirty();
}
return data;
}
public Optional<PendingEntry> pendingEntry(UUID player) {
String id = player.toString();
return pendingEntries.stream().filter(entry -> entry.player.equals(id)).findFirst();
}
public Optional<PendingEntry> prepareEntry(UUID player, List<EntrySlot> slots) {
pendingEntries.removeIf(entry -> entry.player.equals(player.toString()));
if (pendingEntries.size() >= MAX_PENDING_ENTRIES) return Optional.empty();
PendingEntry entry = new PendingEntry(player.toString(), UUID.randomUUID().toString(), copySlots(slots));
pendingEntries.add(entry);
setDirty();
return Optional.of(entry);
}
public void finishEntry(UUID player) {
if (pendingEntries.removeIf(entry -> entry.player.equals(player.toString()))) setDirty();
}
/** Adds a new loss or returns a previously materialized loss to the pool. */
public Optional<UUID> capture(ItemStack source, UUID owner, String cause, long gameTime) {
if (source == null || source.isEmpty()) return Optional.empty();
ItemStack clean = source.copy();
UUID marker = clean.get(SanctuaryRegistries.BACKROOM_LOSS_COMPONENT);
clean.remove(SanctuaryRegistries.BACKROOM_LOSS_COMPONENT);
if (marker != null) {
int existing = indexOfLoss(marker);
if (existing >= 0) {
LostItem previous = lostItems.get(existing);
lostItems.set(existing, new LostItem(previous.id, owner == null ? previous.owner : owner.toString(),
cause, gameTime, clean, LOST_POOL, Long.MIN_VALUE));
setDirty();
return Optional.of(marker);
}
}
if (lostItems.size() >= MAX_LOST_ITEMS) return Optional.empty();
UUID id = marker == null ? UUID.randomUUID() : marker;
lostItems.add(new LostItem(id.toString(), owner == null ? "" : owner.toString(), cause,
gameTime, clean, LOST_POOL, Long.MIN_VALUE));
setDirty();
return Optional.of(id);
}
public Optional<MaterializedLoss> takePersonal(UUID owner, long chestPos) {
String player = owner.toString();
return lostItems.stream()
.filter(loss -> loss.state.equals(LOST_POOL) && loss.owner.equals(player))
.max(Comparator.comparingLong(LostItem::lostAt))
.flatMap(loss -> materialize(loss, chestPos));
}
public Optional<MaterializedLoss> takeShared(long chestPos, long gameTime) {
return lostItems.stream()
.filter(loss -> loss.state.equals(LOST_POOL)
&& sharedEligible(loss.owner, loss.lostAt, gameTime))
.min(Comparator.comparingLong(LostItem::lostAt))
.flatMap(loss -> materialize(loss, chestPos));
}
static boolean sharedEligible(String owner, long lostAt, long gameTime) {
return owner == null || owner.isEmpty()
|| gameTime >= lostAt && gameTime - lostAt >= PERSONAL_RESERVATION_TICKS;
}
private Optional<MaterializedLoss> materialize(LostItem loss, long chestPos) {
int index = lostItems.indexOf(loss);
if (index < 0) return Optional.empty();
ItemStack marked = loss.item.copy();
UUID id = loss.uuid();
marked.set(SanctuaryRegistries.BACKROOM_LOSS_COMPONENT, id);
lostItems.set(index, new LostItem(loss.id, loss.owner, loss.cause, loss.lostAt,
loss.item.copy(), LOST_CHEST, chestPos));
setDirty();
return Optional.of(new MaterializedLoss(id, marked));
}
/** Reconciles real items allocated during the chest's previous loot cycle. */
public void reconcileChest(long chestPos, List<UUID> markersStillPresent) {
List<String> present = markersStillPresent.stream().map(UUID::toString).toList();
boolean changed = false;
for (int index = lostItems.size() - 1; index >= 0; index--) {
LostItem loss = lostItems.get(index);
if (!loss.state.equals(LOST_CHEST) || loss.chest != chestPos) continue;
if (present.contains(loss.id)) {
lostItems.set(index, new LostItem(loss.id, loss.owner, loss.cause, loss.lostAt,
loss.item.copy(), LOST_POOL, Long.MIN_VALUE));
} else {
lostItems.remove(index);
}
changed = true;
}
if (changed) setDirty();
}
/** Settles a marked item that successfully left the Backroom. */
public void settle(UUID lossId) {
if (lossId != null && lostItems.removeIf(loss -> loss.id.equals(lossId.toString()))) setDirty();
}
public Optional<ChestState> chest(long pos) {
Integer index = chestIndices.get(pos);
return index == null ? Optional.empty() : Optional.of(chests.get(index));
}
public void updateChest(long pos, long cycle, List<UUID> assigned) {
ChestState replacement = new ChestState(pos, cycle, assigned.stream().limit(54).map(UUID::toString).toList());
Integer existing = chestIndices.get(pos);
if (existing != null) {
chests.set(existing, replacement);
} else {
if (chests.size() >= MAX_CHEST_STATES) {
chests.removeFirst();
chestIndices.clear();
for (int index = 0; index < chests.size(); index++) chestIndices.put(chests.get(index).pos, index);
}
chestIndices.put(pos, chests.size());
chests.add(replacement);
}
setDirty();
}
/** Marks a chest and returns whether it is a new personal-recovery attempt. */
public ChestVisit visitChest(UUID player, long chestPos) {
PlayerState state = playerState(player);
ArrayList<Long> visited = new ArrayList<>(state.visitedChests);
boolean firstVisit = !visited.contains(chestPos);
if (firstVisit) {
if (visited.size() >= MAX_VISITED_CHESTS) visited.removeFirst();
visited.add(chestPos);
state = replacePlayer(state, state.personalAttempts + 1, visited, state.recentArrivals);
}
return new ChestVisit(firstVisit, state.personalAttempts);
}
public void beginExpedition(UUID player, long packedArrival) {
PlayerState state = playerState(player);
ArrayList<Long> arrivals = new ArrayList<>(state.recentArrivals);
arrivals.remove(packedArrival);
arrivals.addFirst(packedArrival);
while (arrivals.size() > MAX_RECENT_ARRIVALS) arrivals.removeLast();
replacePlayer(state, 0, List.of(), arrivals);
}
public List<Long> recentArrivals(UUID player) {
return List.copyOf(playerState(player).recentArrivals);
}
public int lostItemCount() { return lostItems.size(); }
public long pooledItemCount() { return lostItems.stream().filter(loss -> loss.state.equals(LOST_POOL)).count(); }
public long pooledItemCount(UUID owner) {
String id = owner.toString();
return lostItems.stream().filter(loss -> loss.state.equals(LOST_POOL) && loss.owner.equals(id)).count();
}
public int trackedChestCount() { return chests.size(); }
private PlayerState playerState(UUID player) {
String id = player.toString();
return players.stream().filter(state -> state.player.equals(id)).findFirst().orElseGet(() -> {
if (players.size() >= MAX_PLAYER_STATES) players.removeFirst();
PlayerState state = new PlayerState(id, 0, List.of(), List.of());
players.add(state);
setDirty();
return state;
});
}
private PlayerState replacePlayer(PlayerState old, int attempts, List<Long> visited, List<Long> arrivals) {
PlayerState replacement = new PlayerState(old.player, Math.max(0, attempts),
bounded(visited, MAX_VISITED_CHESTS), bounded(arrivals, MAX_RECENT_ARRIVALS));
players.set(players.indexOf(old), replacement);
setDirty();
return replacement;
}
private int indexOfLoss(UUID id) {
String value = id.toString();
for (int index = 0; index < lostItems.size(); index++) if (lostItems.get(index).id.equals(value)) return index;
return -1;
}
private static List<EntrySlot> copySlots(List<EntrySlot> slots) {
return slots.stream().filter(slot -> slot != null && !slot.item.isEmpty()).limit(MAX_ENTRY_SLOTS)
.map(slot -> new EntrySlot(slot.location, slot.index, slot.item.copy())).toList();
}
private static <T> ArrayList<T> bounded(List<T> source, int maximum) {
List<T> safe = source == null ? List.of() : source;
int start = Math.max(0, safe.size() - maximum);
return new ArrayList<>(safe.subList(start, safe.size()));
}
public record EntrySlot(String location, int index, ItemStack item) {
public EntrySlot { item = item == null ? ItemStack.EMPTY : item.copy(); }
}
public record PendingEntry(String player, String transaction, List<EntrySlot> slots) {
public UUID transactionId() { return UUID.fromString(transaction); }
}
public record LostItem(String id, String owner, String cause, long lostAt, ItemStack item,
String state, long chest) {
public UUID uuid() { return UUID.fromString(id); }
}
public record ChestState(long pos, long cycle, List<String> assigned) {
public List<UUID> assignedIds() { return assigned.stream().map(UUID::fromString).toList(); }
}
private record PlayerState(String player, int personalAttempts, List<Long> visitedChests,
List<Long> recentArrivals) {}
public record MaterializedLoss(UUID id, ItemStack stack) {}
public record ChestVisit(boolean firstVisit, int ordinal) {}
}
@@ -1,108 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.backroom;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryInventoryCapacity;
import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryPlayerProgress;
import fr.koka99cab.sanctuary26.sanctuary.registry.SanctuaryRegistries;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
/** Exact inventory boundary used by Backroom entry, death and exit. */
public final class BackroomInventory {
private static final String INVENTORY = "inventory";
private static final String OVERFLOW = "overflow";
private static final String EGG = "companion_egg";
private static final String CAPE = "cape";
private BackroomInventory() {}
public static List<BackroomData.EntrySlot> snapshot(ServerPlayer player) {
ArrayList<BackroomData.EntrySlot> result = new ArrayList<>();
Inventory inventory = player.getInventory();
for (int slot = 0; slot < inventory.getContainerSize(); slot++) {
add(result, INVENTORY, slot, inventory.getItem(slot));
}
for (int slot = 0; slot < SanctuaryPlayerProgress.OVERFLOW_SLOT_COUNT; slot++) {
add(result, OVERFLOW, slot, SanctuaryInventoryCapacity.overflowItem(player, slot));
}
add(result, EGG, 0, SanctuaryInventoryCapacity.companionEgg(player));
add(result, CAPE, 0, SanctuaryInventoryCapacity.equippedCape(player));
return result;
}
public static List<ItemStack> parcel(List<BackroomData.EntrySlot> slots) {
return slots.stream().map(BackroomData.EntrySlot::item).filter(stack -> !stack.isEmpty())
.map(ItemStack::copy).toList();
}
/** Clears only exact snapshot values, so recovery never destroys a later replacement item. */
public static void clearMatching(ServerPlayer player, List<BackroomData.EntrySlot> slots) {
for (BackroomData.EntrySlot slot : slots) {
ItemStack current = get(player, slot);
if (ItemStack.matches(current, slot.item())) set(player, slot, ItemStack.EMPTY);
}
player.getInventory().setChanged();
SanctuaryInventoryCapacity.markOverflowChanged(player);
}
public static int captureAll(ServerPlayer player, String cause) {
BackroomData data = BackroomData.get(player.level().getServer());
long now = player.level().getGameTime();
int captured = 0;
for (BackroomData.EntrySlot slot : snapshot(player)) {
if (data.capture(slot.item(), player.getUUID(), cause, now).isEmpty()) continue;
set(player, slot, ItemStack.EMPTY);
captured++;
}
player.getInventory().setChanged();
SanctuaryInventoryCapacity.markOverflowChanged(player);
return captured;
}
/** Removes registry markers only when their real item successfully crosses a bed exit. */
public static int settleCarriedLosses(ServerPlayer player) {
BackroomData data = BackroomData.get(player.level().getServer());
int settled = 0;
for (BackroomData.EntrySlot slot : snapshot(player)) {
ItemStack item = get(player, slot);
UUID id = item.get(SanctuaryRegistries.BACKROOM_LOSS_COMPONENT);
if (id == null) continue;
item.remove(SanctuaryRegistries.BACKROOM_LOSS_COMPONENT);
data.settle(id);
set(player, slot, item);
settled++;
}
return settled;
}
private static void add(List<BackroomData.EntrySlot> target, String location, int index, ItemStack stack) {
if (stack != null && !stack.isEmpty()) target.add(new BackroomData.EntrySlot(location, index, stack));
}
private static ItemStack get(ServerPlayer player, BackroomData.EntrySlot slot) {
return switch (slot.location()) {
case INVENTORY -> slot.index() >= 0 && slot.index() < player.getInventory().getContainerSize()
? player.getInventory().getItem(slot.index()) : ItemStack.EMPTY;
case OVERFLOW -> SanctuaryInventoryCapacity.overflowItem(player, slot.index());
case EGG -> SanctuaryInventoryCapacity.companionEgg(player);
case CAPE -> SanctuaryInventoryCapacity.equippedCape(player);
default -> ItemStack.EMPTY;
};
}
private static void set(ServerPlayer player, BackroomData.EntrySlot slot, ItemStack stack) {
switch (slot.location()) {
case INVENTORY -> {
if (slot.index() >= 0 && slot.index() < player.getInventory().getContainerSize())
player.getInventory().setItem(slot.index(), stack);
}
case OVERFLOW -> SanctuaryInventoryCapacity.setOverflowItem(player, slot.index(), stack);
case EGG -> SanctuaryInventoryCapacity.setCompanionEgg(player, stack);
case CAPE -> SanctuaryInventoryCapacity.setEquippedCape(player, stack);
default -> { }
}
}
}
@@ -1,262 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.backroom;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import fr.koka99cab.sanctuary26.sanctuary.shop.ShopData;
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.BackroomArchitectureFeature;
import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.BackroomLayout;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerEntityEvents;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.commands.arguments.EntityArgument;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.Mth;
import net.minecraft.util.RandomSource;
import net.minecraft.world.entity.MobCategory;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.portal.TeleportTransition;
import net.minecraft.world.phys.Vec3;
/** Bed gateway and crash-safe Mailbox boundary for Backroom expeditions. */
public final class BackroomService {
public static final int ARRIVAL_RADIUS = 2_000;
private static final int ARRIVAL_ATTEMPTS = 48;
private static final Set<UUID> AUTHORIZED_TRANSITIONS = new HashSet<>();
private static boolean initialized;
private BackroomService() {}
public static void initialize() {
if (initialized) return;
initialized = true;
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> reconcile(handler.player));
ServerEntityEvents.ENTITY_LOAD.register((entity, level) -> {
if (isBackroom(level) && entity.getType().getCategory() == MobCategory.MONSTER) entity.discard();
});
CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> {
dispatcher.register(Commands.literal("backroom")
.requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS))
.executes(context -> status(context.getSource()))
.then(Commands.literal("status").executes(context -> status(context.getSource())))
.then(Commands.literal("inspect")
.then(Commands.argument("player", EntityArgument.player())
.executes(context -> inspect(context.getSource(), EntityArgument.getPlayer(context, "player"))))));
});
SanctuaryMod.LOGGER.info("[{}] Backroom bed gateway ready (arrival radius {} blocks).",
SanctuaryMod.DISPLAY_NAME, ARRIVAL_RADIUS);
}
/** Returns true when the bed interaction belongs to the Backroom feature. */
public static boolean useBed(ServerPlayer player) {
if (isBackroom(player.level())) {
exit(player);
return true;
}
if (!player.isSecondaryUseActive()) return false;
enter(player);
return true;
}
public static boolean isBackroom(ServerLevel level) {
return level != null && level.dimension().equals(SanctuaryDimensions.BACKROOM);
}
public static boolean enter(ServerPlayer player) {
ServerLevel destination = player.level().getServer().getLevel(SanctuaryDimensions.BACKROOM);
if (destination == null) {
notify(player, "message.sanctuary.backroom.unavailable");
return false;
}
if (!reconcile(player)) {
notify(player, "message.sanctuary.backroom.pending");
return false;
}
List<BackroomData.EntrySlot> slots = BackroomInventory.snapshot(player);
ShopData.DeliveryDestination mailbox = null;
if (!slots.isEmpty()) {
mailbox = ShopData.get(player.level().getServer()).loadedDeliveryBox(
player.level().getServer(), player.getUUID(), level -> !isBackroom(level)).orElse(null);
if (mailbox == null) {
notify(player, "message.sanctuary.backroom.mailbox_missing");
return false;
}
}
BackroomData data = BackroomData.get(player.level().getServer());
BackroomData.PendingEntry pending = data.prepareEntry(player.getUUID(), slots).orElse(null);
if (pending == null) {
notify(player, "message.sanctuary.backroom.registry_busy");
return false;
}
if (mailbox != null && !mailbox.box().insertTransaction(pending.transactionId(), BackroomInventory.parcel(slots))) {
data.finishEntry(player.getUUID());
notify(player, "message.sanctuary.backroom.mailbox_full");
return false;
}
BackroomInventory.clearMatching(player, slots);
data.finishEntry(player.getUUID());
BlockPos arrival = chooseArrival(destination, player.getUUID(), data);
placeArrivalTorch(destination, arrival);
data.beginExpedition(player.getUUID(), arrival.asLong());
TeleportTransition transition = new TeleportTransition(destination, Vec3.atBottomCenterOf(arrival),
Vec3.ZERO, player.getYRot(), player.getXRot(),
TeleportTransition.PLAY_PORTAL_SOUND.then(TeleportTransition.PLACE_PORTAL_TICKET));
ServerPlayer teleported;
AUTHORIZED_TRANSITIONS.add(player.getUUID());
try {
teleported = player.teleport(transition);
} finally {
AUTHORIZED_TRANSITIONS.remove(player.getUUID());
}
if (teleported == null) {
SanctuaryMod.LOGGER.error("Backroom teleport failed after Mailbox transaction {} for {}",
pending.transaction(), player.getGameProfile().name());
return false;
}
notify(player, "message.sanctuary.backroom.entered");
return true;
}
public static boolean exit(ServerPlayer player) {
if (!isBackroom(player.level())) return false;
TeleportTransition transition = player.findRespawnPositionAndUseSpawnBlock(false,
TeleportTransition.PLAY_PORTAL_SOUND.then(TeleportTransition.PLACE_PORTAL_TICKET));
if (isBackroom(transition.newLevel())) {
transition = TeleportTransition.createDefault(player,
TeleportTransition.PLAY_PORTAL_SOUND.then(TeleportTransition.PLACE_PORTAL_TICKET));
}
ServerPlayer teleported;
AUTHORIZED_TRANSITIONS.add(player.getUUID());
try {
teleported = player.teleport(transition);
} finally {
AUTHORIZED_TRANSITIONS.remove(player.getUUID());
}
if (teleported == null) return false;
int recovered = BackroomInventory.settleCarriedLosses(teleported);
notify(teleported, recovered == 0
? "message.sanctuary.backroom.exited"
: "message.sanctuary.backroom.exited_with_losses");
return true;
}
public static boolean isAuthorizedTransition(UUID player) {
return player != null && AUTHORIZED_TRANSITIONS.contains(player);
}
/** Resolves a crash between Mailbox insertion and inventory clearing. */
public static boolean reconcile(ServerPlayer player) {
BackroomData data = BackroomData.get(player.level().getServer());
BackroomData.PendingEntry pending = data.pendingEntry(player.getUUID()).orElse(null);
if (pending == null) return true;
ShopData.DeliveryDestination mailbox = ShopData.get(player.level().getServer())
.loadedDeliveryBox(player.level().getServer(), player.getUUID(), level -> !isBackroom(level)).orElse(null);
if (mailbox == null) return false;
if (mailbox.box().hasProcessedTransaction(pending.transactionId())) {
BackroomInventory.clearMatching(player, pending.slots());
}
data.finishEntry(player.getUUID());
return true;
}
private static BlockPos chooseArrival(ServerLevel level, UUID player, BackroomData data) {
RandomSource random = level.getRandom();
List<Long> recent = data.recentArrivals(player);
int arrivalY = BackroomLayout.ENTRY_FLOOR_Y + 1;
BlockPos best = null;
for (int attempt = 0; attempt < ARRIVAL_ATTEMPTS; attempt++) {
double angle = random.nextDouble() * Math.PI * 2.0D;
double radius = Math.sqrt(random.nextDouble()) * ARRIVAL_RADIUS;
int x = Mth.floor(Math.cos(angle) * radius);
int z = Mth.floor(Math.sin(angle) * radius);
BlockPos candidate = new BlockPos(x, arrivalY, z);
best = candidate;
if (farFromRecent(candidate, recent) && safe(level, candidate)) return candidate;
}
BlockPos fallback = findSafeNear(level, best == null ? BlockPos.ZERO : best, 32);
if (fallback != null) return fallback;
fallback = findSafeNear(level, BlockPos.ZERO, 48);
if (fallback != null) return fallback;
return new BlockPos(8, arrivalY, 8);
}
private static boolean farFromRecent(BlockPos candidate, List<Long> recent) {
for (long packed : recent) {
BlockPos old = BlockPos.of(packed);
long dx = old.getX() - candidate.getX(), dz = old.getZ() - candidate.getZ();
if (dx * dx + dz * dz < 128L * 128L) return false;
}
return true;
}
private static boolean safe(ServerLevel level, BlockPos feet) {
return level.getBlockState(feet).isAir() && level.getBlockState(feet.above()).isAir()
&& level.getBlockState(feet.below()).isCollisionShapeFullBlock(level, feet.below());
}
private static BlockPos findSafeNear(ServerLevel level, BlockPos center, int radius) {
for (int ring = 0; ring <= radius; ring += 2) {
for (int dx = -ring; dx <= ring; dx += 2) for (int dz = -ring; dz <= ring; dz += 2) {
if (ring > 0 && Math.abs(dx) != ring && Math.abs(dz) != ring) continue;
int x = center.getX() + dx, z = center.getZ() + dz;
if ((long) x * x + (long) z * z > (long) ARRIVAL_RADIUS * ARRIVAL_RADIUS) continue;
BlockPos candidate = new BlockPos(x, BackroomLayout.ENTRY_FLOOR_Y + 1, z);
if (safe(level, candidate)) return candidate;
}
}
return null;
}
private static void placeArrivalTorch(ServerLevel level, BlockPos arrival) {
level.setBlock(arrival.below(), Blocks.BIRCH_PLANKS.defaultBlockState(), 3);
level.setBlock(arrival, Blocks.AIR.defaultBlockState(), 3);
level.setBlock(arrival.above(), Blocks.AIR.defaultBlockState(), 3);
for (var direction : net.minecraft.core.Direction.Plane.HORIZONTAL) {
BlockPos torch = arrival.relative(direction);
if (!BackroomArchitectureFeature.withinPlayableArea(torch.getX(), torch.getZ())) continue;
if (level.getBlockState(torch).isAir()
&& level.getBlockState(torch.below()).isCollisionShapeFullBlock(level, torch.below())) {
level.setBlock(torch, Blocks.TORCH.defaultBlockState(), 3);
return;
}
}
BlockPos torch = arrival.east();
if (!BackroomArchitectureFeature.withinPlayableArea(torch.getX(), torch.getZ())) torch = arrival.west();
level.setBlock(torch.below(), Blocks.BIRCH_PLANKS.defaultBlockState(), 3);
level.setBlock(torch, Blocks.TORCH.defaultBlockState(), 3);
}
private static void notify(ServerPlayer player, String translationKey) {
player.sendOverlayMessage(Component.translatable(translationKey));
}
private static int status(CommandSourceStack source) {
BackroomData data = BackroomData.get(source.getServer());
source.sendSuccess(() -> Component.literal("Backroom: " + data.lostItemCount() + " lost stacks ("
+ data.pooledItemCount() + " pooled), " + data.trackedChestCount() + " tracked chest positions; data v"
+ BackroomData.DATA_VERSION + "."), false);
return data.lostItemCount();
}
private static int inspect(CommandSourceStack source, ServerPlayer player) {
BackroomData data = BackroomData.get(source.getServer());
boolean pending = data.pendingEntry(player.getUUID()).isPresent();
source.sendSuccess(() -> Component.literal("Backroom player " + player.getGameProfile().name() + ": "
+ data.pooledItemCount(player.getUUID()) + " personal pooled stacks, entry transaction "
+ (pending ? "pending" : "clear") + ", " + data.recentArrivals(player.getUUID()).size()
+ " remembered arrivals."), false);
return pending ? 0 : 1;
}
}
@@ -30,8 +30,6 @@ import net.minecraft.world.item.equipment.Equippable;
/** Renders a banner-decorated cape with vanilla cape physics and banner pattern sprites. */
public final class SanctuaryBannerCapeLayer extends RenderLayer<AvatarRenderState, PlayerModel> {
private static final float SHIELD_PATTERN_X_SCALE = 1.2F;
private static final float SHIELD_PATTERN_Y_SCALE = 1.375F;
private static final ModelLayerLocation MODEL_LAYER =
new ModelLayerLocation(SanctuaryMod.id("banner_cape"), "main");
private final PlayerCapeModel model;
@@ -69,7 +67,7 @@ public final class SanctuaryBannerCapeLayer extends RenderLayer<AvatarRenderStat
PartDefinition body = root.getChild("body");
body.addOrReplaceChild("cape", CubeListBuilder.create().texOffs(0, 0)
.addBox(-5.0F, 0.0F, -1.0F, 10.0F, 16.0F, 1.0F,
CubeDeformation.NONE, SHIELD_PATTERN_X_SCALE, SHIELD_PATTERN_Y_SCALE),
CubeDeformation.NONE, 2.0F, 2.5F),
PartPose.offsetAndRotation(0.0F, 0.0F, 2.0F, 0.0F, (float) Math.PI, 0.0F));
return LayerDefinition.create(mesh, 64, 64);
}
@@ -87,7 +85,7 @@ public final class SanctuaryBannerCapeLayer extends RenderLayer<AvatarRenderStat
poseStack.translate(0.0F, -0.053125F, 0.06875F);
}
BannerRenderer.submitPatterns(context.getSprites(), poseStack, collector, packedLight,
OverlayTexture.NO_OVERLAY, model, state, false, appearance.baseColor(),
OverlayTexture.NO_OVERLAY, model, state, true, appearance.baseColor(),
appearance.patterns(), null);
poseStack.popPose();
}
@@ -1,64 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.client;
import com.mojang.blaze3d.vertex.PoseStack;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import net.fabricmc.fabric.api.client.rendering.v1.LivingEntityRenderLayerRegistrationCallback;
import net.fabricmc.fabric.api.client.rendering.v1.ModelLayerRegistry;
import net.minecraft.client.model.animal.chicken.ChickenModel;
import net.minecraft.client.model.geom.ModelLayerLocation;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.entity.ChickenRenderer;
import net.minecraft.client.renderer.entity.EntityRendererProvider;
import net.minecraft.client.renderer.entity.RenderLayerParent;
import net.minecraft.client.renderer.entity.layers.EquipmentLayerRenderer;
import net.minecraft.client.renderer.entity.layers.RenderLayer;
import net.minecraft.client.renderer.entity.state.ChickenRenderState;
import net.minecraft.client.resources.model.EquipmentClientInfo;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.equipment.Equippable;
/** Renders vanilla armadillo-scute equipment around an adult chicken's torso. */
public final class SanctuaryChickenArmorLayer extends RenderLayer<ChickenRenderState, ChickenModel> {
private static final ModelLayerLocation MODEL_LAYER =
new ModelLayerLocation(SanctuaryMod.id("chicken_armor"), "main");
private final SanctuaryChickenArmorModel model;
private final EquipmentLayerRenderer equipmentRenderer;
private SanctuaryChickenArmorLayer(RenderLayerParent<ChickenRenderState, ChickenModel> parent,
EntityRendererProvider.Context context) {
super(parent);
model = SanctuaryChickenArmorModel.bake(context.bakeLayer(MODEL_LAYER));
equipmentRenderer = context.getEquipmentRenderer();
}
public static void initialize() {
ModelLayerRegistry.registerModelLayer(MODEL_LAYER, SanctuaryChickenArmorModel::createLayer);
LivingEntityRenderLayerRegistrationCallback.EVENT.register((entityType, renderer, helper, context) -> {
if (entityType == EntityTypes.CHICKEN && renderer instanceof ChickenRenderer chickenRenderer) {
register(helper, chickenRenderer, context);
}
});
}
@SuppressWarnings({"unchecked", "rawtypes"})
private static void register(LivingEntityRenderLayerRegistrationCallback.RegistrationHelper helper,
ChickenRenderer renderer, EntityRendererProvider.Context context) {
RenderLayerParent<ChickenRenderState, ChickenModel> parent =
(RenderLayerParent<ChickenRenderState, ChickenModel>) (RenderLayerParent) renderer;
helper.register(new SanctuaryChickenArmorLayer(parent, context));
}
@Override
public void submit(PoseStack poseStack, SubmitNodeCollector collector, int packedLight,
ChickenRenderState state, float yRot, float xRot) {
if (state.isBaby || state.isInvisibleToPlayer) return;
ItemStack armor = ((SanctuaryChickenArmorRenderState) state).sanctuary$chickenArmor();
Equippable equippable = armor.get(DataComponents.EQUIPPABLE);
if (equippable == null || equippable.assetId().isEmpty()) return;
equipmentRenderer.renderLayers(EquipmentClientInfo.LayerType.WOLF_BODY,
equippable.assetId().get(), model, state, armor, poseStack, collector,
packedLight, state.outlineColor);
}
}
@@ -1,32 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.client;
import net.minecraft.client.model.EntityModel;
import net.minecraft.client.model.geom.ModelPart;
import net.minecraft.client.model.geom.PartPose;
import net.minecraft.client.model.geom.builders.CubeDeformation;
import net.minecraft.client.model.geom.builders.CubeListBuilder;
import net.minecraft.client.model.geom.builders.LayerDefinition;
import net.minecraft.client.model.geom.builders.MeshDefinition;
import net.minecraft.client.renderer.entity.state.ChickenRenderState;
/** A thin torso shell using the vanilla armadillo-scute equipment UVs. */
public final class SanctuaryChickenArmorModel extends EntityModel<ChickenRenderState> {
private SanctuaryChickenArmorModel(ModelPart root) {
super(root);
}
public static SanctuaryChickenArmorModel bake(ModelPart root) {
return new SanctuaryChickenArmorModel(root);
}
public static LayerDefinition createLayer() {
MeshDefinition mesh = new MeshDefinition();
mesh.getRoot().addOrReplaceChild("body",
CubeListBuilder.create().texOffs(18, 14)
.addBox(-3.0F, -4.0F, -3.0F, 6.0F, 8.0F, 6.0F,
new CubeDeformation(0.45F)),
PartPose.offsetAndRotation(0.0F, 16.0F, 0.0F,
(float) (Math.PI / 2.0D), 0.0F, 0.0F));
return LayerDefinition.create(mesh, 64, 32);
}
}
@@ -1,10 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.client;
import net.minecraft.world.item.ItemStack;
/** Mixin-backed bridge carrying chicken body armor into the extracted render state. */
public interface SanctuaryChickenArmorRenderState {
ItemStack sanctuary$chickenArmor();
void sanctuary$setChickenArmor(ItemStack armor);
}
@@ -120,7 +120,6 @@ public final class SanctuaryClient implements ClientModInitializer {
SanctuaryHealthPreview.register();
CelestialSpyglassSelection.register();
SanctuaryBannerCapeLayer.initialize();
SanctuaryChickenArmorLayer.initialize();
EntityRendererRegistry.register(SanctuaryRegistries.SEAT, NoopRenderer::new);
ClientTickEvents.END_CLIENT_TICK.register(SanctuaryClient::tick);
ClientPlayNetworking.registerGlobalReceiver(ProgressionSnapshotPayload.ID, (payload, context) ->
@@ -1,25 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.gameplay;
import net.minecraft.tags.DamageTypeTags;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Shared server rules for fitting vanilla armadillo-scute armor to adult chickens. */
public final class SanctuaryChickenArmor {
private SanctuaryChickenArmor() {
}
public static boolean canEquip(ItemStack equipped, ItemStack held, boolean baby) {
return !baby && equipped.isEmpty() && held.is(Items.WOLF_ARMOR);
}
public static boolean canRepair(ItemStack equipped, ItemStack held) {
return equipped.is(Items.WOLF_ARMOR) && equipped.isDamaged()
&& equipped.isValidRepairItem(held);
}
public static boolean absorbs(ItemStack equipped, DamageSource source) {
return equipped.is(Items.WOLF_ARMOR) && !source.is(DamageTypeTags.BYPASSES_WOLF_ARMOR);
}
}
@@ -1,43 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.mixin;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryChickenArmor;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.util.Mth;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.animal.Animal;
import net.minecraft.world.entity.animal.chicken.Chicken;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** Lets adult chickens wear and repair the vanilla armadillo-scute wolf armor. */
@Mixin(Animal.class)
abstract class AnimalChickenArmorMixin {
@Inject(method = "mobInteract", at = @At("HEAD"), cancellable = true)
private void sanctuary$handleChickenArmor(Player player, InteractionHand hand,
CallbackInfoReturnable<InteractionResult> cir) {
if (!((Object) this instanceof Chicken chicken)) return;
ItemStack held = player.getItemInHand(hand);
ItemStack equipped = chicken.getItemBySlot(EquipmentSlot.BODY);
if (SanctuaryChickenArmor.canEquip(equipped, held, chicken.isBaby())) {
chicken.setItemSlot(EquipmentSlot.BODY, held.copyWithCount(1));
chicken.setGuaranteedDrop(EquipmentSlot.BODY);
held.consume(1, player);
chicken.playSound(SoundEvents.ARMOR_EQUIP_WOLF.value());
cir.setReturnValue(InteractionResult.SUCCESS);
return;
}
if (SanctuaryChickenArmor.canRepair(equipped, held)) {
held.consume(1, player);
int repairUnit = Mth.ceil(equipped.getMaxDamage() * 0.125F);
equipped.setDamageValue(Math.max(0, equipped.getDamageValue() - repairUnit));
chicken.playSound(SoundEvents.WOLF_ARMOR_REPAIR);
cir.setReturnValue(InteractionResult.SUCCESS);
}
}
}
@@ -1,26 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.mixin;
import fr.koka99cab.sanctuary26.sanctuary.backroom.BackroomService;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BedBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(BedBlock.class)
abstract class BedBlockMixin {
@Inject(method = "useWithoutItem", at = @At("HEAD"), cancellable = true)
private void sanctuary$useBackroomBed(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit, CallbackInfoReturnable<InteractionResult> callback) {
if (player instanceof ServerPlayer serverPlayer && BackroomService.useBed(serverPlayer)) {
callback.setReturnValue(InteractionResult.SUCCESS_SERVER);
}
}
}
@@ -1,20 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.mixin;
import fr.koka99cab.sanctuary26.sanctuary.backroom.BackroomChestService;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.ContainerUser;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(ChestBlockEntity.class)
abstract class ChestBlockEntityMixin {
@Inject(method = "startOpen", at = @At("HEAD"))
private void sanctuary$refreshBackroomChest(ContainerUser user, CallbackInfo callback) {
if (user.getLivingEntity() instanceof ServerPlayer player) {
BackroomChestService.open((ChestBlockEntity) (Object) this, player);
}
}
}
@@ -1,35 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.mixin;
import fr.koka99cab.sanctuary26.sanctuary.backroom.BackroomData;
import java.util.UUID;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.item.ItemEntity;
import org.jspecify.annotations.Nullable;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(ItemEntity.class)
abstract class ItemEntityMixin {
@Shadow private @Nullable UUID target;
@Inject(method = "tick", at = @At("HEAD"), cancellable = true)
private void sanctuary$captureLostItem(CallbackInfo callback) {
ItemEntity item = (ItemEntity) (Object) this;
if (!(item.level() instanceof ServerLevel level) || item.getItem().isEmpty()) return;
boolean voidLoss = item.getY() < level.getMinY() - 64.0D;
boolean despawn = item.getAge() >= 5_999;
if (!voidLoss && !despawn) return;
UUID owner = target;
Entity thrower = item.getOwner();
if (owner == null && thrower instanceof ServerPlayer player) owner = player.getUUID();
if (BackroomData.get(level.getServer()).capture(item.getItem(), owner,
voidLoss ? "void" : "despawn", level.getGameTime()).isEmpty()) return;
item.discard();
callback.cancel();
}
}
@@ -2,11 +2,9 @@ package fr.koka99cab.sanctuary26.sanctuary.mixin;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryCompanionPowers;
import java.util.function.Consumer;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.SpawnEggItem;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
@@ -23,9 +21,5 @@ abstract class ItemTooltipMixin {
if (stack.getItem() instanceof SpawnEggItem) {
SanctuaryCompanionPowers.appendPowerTooltip(stack, tooltip);
}
if (stack.is(Items.WOLF_ARMOR)) {
tooltip.accept(Component.translatable("tooltip.sanctuary.chicken_armor")
.withStyle(ChatFormatting.GRAY));
}
}
}
@@ -1,44 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.mixin;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryChickenArmor;
import net.minecraft.core.particles.ItemParticleOption;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.util.Mth;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.entity.Crackiness;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.animal.chicken.Chicken;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Gives armored chickens the same damage-absorption contract as armored wolves. */
@Mixin(LivingEntity.class)
abstract class LivingEntityChickenArmorMixin {
@Inject(method = "actuallyHurt", at = @At("HEAD"), cancellable = true)
private void sanctuary$absorbChickenDamage(ServerLevel level, DamageSource source,
float damage, CallbackInfo ci) {
if (!((Object) this instanceof Chicken chicken)) return;
ItemStack armor = chicken.getItemBySlot(EquipmentSlot.BODY);
if (!SanctuaryChickenArmor.absorbs(armor, source)) return;
int damageBefore = armor.getDamageValue();
int maxDamage = armor.getMaxDamage();
armor.hurtAndBreak(Mth.ceil(damage), chicken, EquipmentSlot.BODY);
chicken.playSound(SoundEvents.WOLF_ARMOR_DAMAGE);
if (Crackiness.WOLF_ARMOR.byDamage(damageBefore, maxDamage)
!= Crackiness.WOLF_ARMOR.byDamage(chicken.getItemBySlot(EquipmentSlot.BODY))) {
chicken.playSound(SoundEvents.WOLF_ARMOR_CRACK);
level.sendParticles(new ItemParticleOption(ParticleTypes.ITEM,
Items.ARMADILLO_SCUTE), chicken.getX(), chicken.getY() + 0.5D,
chicken.getZ(), 12, 0.2D, 0.15D, 0.2D, 0.08D);
}
ci.cancel();
}
}
@@ -1,44 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.mixin;
import fr.koka99cab.sanctuary26.sanctuary.backroom.BackroomInventory;
import fr.koka99cab.sanctuary26.sanctuary.backroom.BackroomService;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.damagesource.DamageTypes;
import net.minecraft.world.level.portal.TeleportTransition;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(ServerPlayer.class)
abstract class ServerPlayerBackroomMixin {
@Inject(method = "teleport", at = @At("HEAD"), cancellable = true)
private void sanctuary$enforceBackroomExtraction(TeleportTransition transition,
CallbackInfoReturnable<ServerPlayer> callback) {
ServerPlayer player = (ServerPlayer) (Object) this;
boolean leaving = BackroomService.isBackroom(player.level())
&& !BackroomService.isBackroom(transition.newLevel());
boolean entering = !BackroomService.isBackroom(player.level())
&& BackroomService.isBackroom(transition.newLevel());
if ((!leaving && !entering) || BackroomService.isAuthorizedTransition(player.getUUID())) return;
if (entering) {
player.sendOverlayMessage(Component.translatable("message.sanctuary.backroom.bed_entry_only"));
callback.setReturnValue(null);
return;
}
BackroomInventory.captureAll(player, "backroom_forced_exit");
}
@Inject(method = "die", at = @At("HEAD"))
private void sanctuary$captureBackroomDeath(DamageSource source, CallbackInfo callback) {
ServerPlayer player = (ServerPlayer) (Object) this;
if (BackroomService.isBackroom(player.level())) {
BackroomInventory.captureAll(player, "backroom_death");
} else if (source.is(DamageTypes.FELL_OUT_OF_WORLD)) {
BackroomInventory.captureAll(player, "void_death");
}
}
}
@@ -1,23 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.mixin.client;
import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryChickenArmorRenderState;
import net.minecraft.client.renderer.entity.state.ChickenRenderState;
import net.minecraft.world.item.ItemStack;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
/** Stores synchronized chicken body armor on the client render state. */
@Mixin(ChickenRenderState.class)
abstract class ChickenRenderStateMixin implements SanctuaryChickenArmorRenderState {
@Unique private ItemStack sanctuary$chickenArmor = ItemStack.EMPTY;
@Override
public ItemStack sanctuary$chickenArmor() {
return sanctuary$chickenArmor;
}
@Override
public void sanctuary$setChickenArmor(ItemStack armor) {
sanctuary$chickenArmor = armor == null ? ItemStack.EMPTY : armor;
}
}
@@ -1,22 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.mixin.client;
import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryChickenArmorRenderState;
import net.minecraft.client.renderer.entity.ChickenRenderer;
import net.minecraft.client.renderer.entity.state.ChickenRenderState;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.animal.chicken.Chicken;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Copies vanilla-synchronized body equipment into each chicken render state. */
@Mixin(ChickenRenderer.class)
abstract class ChickenRendererMixin {
@Inject(method = "extractRenderState", at = @At("TAIL"))
private void sanctuary$extractChickenArmor(Chicken chicken, ChickenRenderState state,
float partialTick, CallbackInfo ci) {
((SanctuaryChickenArmorRenderState) state).sanctuary$setChickenArmor(
chicken.getItemBySlot(EquipmentSlot.BODY).copy());
}
}
@@ -9,9 +9,6 @@ import fr.koka99cab.sanctuary26.sanctuary.shop.QuestRequestSignItem;
import fr.koka99cab.sanctuary26.sanctuary.shop.QuestSignItem;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryCapes;
import java.util.function.Function;
import java.util.UUID;
import net.minecraft.core.UUIDUtil;
import net.minecraft.core.component.DataComponentType;
import net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents;
import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityType;
import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;
@@ -40,16 +37,6 @@ import net.minecraft.world.level.material.MapColor;
* Feature registries must be called from here to keep initialization ordering explicit.
*/
public final class SanctuaryRegistries {
public static final Identifier BACKROOM_LOSS_ID = SanctuaryMod.id("backroom_loss_id");
public static final DataComponentType<UUID> BACKROOM_LOSS_COMPONENT = Registry.register(
BuiltInRegistries.DATA_COMPONENT_TYPE,
BACKROOM_LOSS_ID,
DataComponentType.<UUID>builder()
.persistent(UUIDUtil.CODEC)
.networkSynchronized(UUIDUtil.STREAM_CODEC)
.cacheEncoding()
.build()
);
public static final Identifier DELIVERY_BOX_ID = SanctuaryMod.id("delivery_box");
public static final DeliveryBoxBlock DELIVERY_BOX = registerBlock(
DELIVERY_BOX_ID,
@@ -1,10 +1,6 @@
package fr.koka99cab.sanctuary26.sanctuary.shop;
import fr.koka99cab.sanctuary26.sanctuary.registry.SanctuaryRegistries;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.core.NonNullList;
@@ -23,9 +19,7 @@ import net.minecraft.world.level.storage.ValueOutput;
public final class DeliveryBoxBlockEntity extends BaseContainerBlockEntity {
public static final int SIZE = 27;
private static final int MAX_PROCESSED_TRANSACTIONS = 128;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private final Set<UUID> processedTransactions = new LinkedHashSet<>();
private UUID owner;
public DeliveryBoxBlockEntity(BlockPos pos, BlockState state) {
@@ -74,68 +68,16 @@ public final class DeliveryBoxBlockEntity extends BaseContainerBlockEntity {
return remaining.isEmpty();
}
/**
* Atomically inserts a complete Backroom parcel. Replaying the same transaction is a no-op,
* which makes the mailbox side of an entry safe across a server crash.
*/
public boolean insertTransaction(UUID transactionId, List<ItemStack> deliveries) {
if (transactionId == null || deliveries == null) return false;
if (processedTransactions.contains(transactionId)) return true;
NonNullList<ItemStack> simulated = copyItems(items);
for (ItemStack delivery : deliveries) {
if (delivery == null || delivery.isEmpty()) continue;
if (!insertInto(simulated, delivery.copy())) return false;
}
items = simulated;
processedTransactions.add(transactionId);
while (processedTransactions.size() > MAX_PROCESSED_TRANSACTIONS) {
processedTransactions.remove(processedTransactions.iterator().next());
}
setChanged();
return true;
}
public boolean hasProcessedTransaction(UUID transactionId) {
return transactionId != null && processedTransactions.contains(transactionId);
}
private static NonNullList<ItemStack> copyItems(List<ItemStack> source) {
NonNullList<ItemStack> copy = NonNullList.withSize(SIZE, ItemStack.EMPTY);
for (int slot = 0; slot < Math.min(SIZE, source.size()); slot++) copy.set(slot, source.get(slot).copy());
return copy;
}
private static boolean insertInto(NonNullList<ItemStack> target, ItemStack offered) {
for (int slot = 0; slot < target.size() && !offered.isEmpty(); slot++) {
ItemStack existing = target.get(slot);
if (existing.isEmpty() || !ItemStack.isSameItemSameComponents(existing, offered)) continue;
int moved = Math.min(offered.getCount(), existing.getMaxStackSize() - existing.getCount());
if (moved > 0) { existing.grow(moved); offered.shrink(moved); }
}
for (int slot = 0; slot < target.size() && !offered.isEmpty(); slot++) {
if (!target.get(slot).isEmpty()) continue;
int moved = Math.min(offered.getCount(), offered.getMaxStackSize());
target.set(slot, offered.copyWithCount(moved));
offered.shrink(moved);
}
return offered.isEmpty();
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
ContainerHelper.saveAllItems(output, items);
if (owner != null) output.store("Owner", UUIDUtil.CODEC, owner);
output.store("BackroomTransactions", UUIDUtil.CODEC.listOf(0, MAX_PROCESSED_TRANSACTIONS),
new ArrayList<>(processedTransactions));
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
ContainerHelper.loadAllItems(input, items);
owner = input.read("Owner", UUIDUtil.CODEC).orElse(null);
processedTransactions.clear();
input.read("BackroomTransactions", UUIDUtil.CODEC.listOf(0, MAX_PROCESSED_TRANSACTIONS)).orElse(List.of()).stream()
.limit(MAX_PROCESSED_TRANSACTIONS).forEach(processedTransactions::add);
}
@Override public int getContainerSize() { return SIZE; }
@Override protected NonNullList<ItemStack> getItems() { return items; }
@@ -425,11 +425,8 @@ public final class QuestRequestService {
"message.sanctuary.quest_request.details.reward_legacy").withStyle(ChatFormatting.GRAY));
QuestRequestData.Claim claim = request.claim().orElse(null);
if (request.completed()) {
Component completion = claim == null
? Component.translatable("message.sanctuary.quest_request.details.completed")
: Component.translatable("message.sanctuary.quest_request.details.completed_by",
claimantName(claim));
player.sendSystemMessage(completion.copy().withStyle(ChatFormatting.GREEN));
player.sendSystemMessage(Component.translatable("message.sanctuary.quest_request.details.completed")
.withStyle(ChatFormatting.GREEN));
} else if (claim != null) {
Component claimant = Component.literal(claim.playerName())
.withStyle(style -> style.withColor(claim.playerColor()));
@@ -447,11 +444,6 @@ public final class QuestRequestService {
return Component.translatable("message.sanctuary.quest_request.action." + action);
}
private static Component claimantName(QuestRequestData.Claim claim) {
return Component.literal(claim.playerName())
.withStyle(style -> style.withColor(claim.playerColor()));
}
private static Component describeTarget(QuestRequestData.Request request) {
if (PAY.equals(request.action()) || PLAYER.equals(request.targetKind())) {
return Component.literal(request.target());
@@ -493,20 +485,13 @@ public final class QuestRequestService {
if (request.completed()) {
text = text.setMessage(2, Component.translatable("sign.sanctuary.quest_request.completed")
.withStyle(ChatFormatting.DARK_GRAY));
QuestRequestData.Claim claim = request.claim().orElse(null);
text = text.setMessage(3, claim == null
? Component.translatable("sign.sanctuary.quest_request.details")
.withStyle(ChatFormatting.GRAY)
: Component.translatable("sign.sanctuary.quest_request.completed_by", claimantName(claim))
.withStyle(ChatFormatting.GRAY));
} else if (request.claim().isPresent()) {
text = text.setMessage(2, Component.translatable("sign.sanctuary.quest_request.claimed")
.withStyle(ChatFormatting.DARK_GRAY));
} else text = text.setMessage(2, Component.translatable("sign.sanctuary.quest_request.available")
.withStyle(ChatFormatting.DARK_GRAY));
if (!request.completed()) text = text.setMessage(3,
Component.translatable("sign.sanctuary.quest_request.details")
.withStyle(ChatFormatting.GRAY));
text = text.setMessage(3, Component.translatable("sign.sanctuary.quest_request.details")
.withStyle(ChatFormatting.GRAY));
}
sign.setText(text, request.front());
sign.setWaxed(true);
@@ -8,7 +8,6 @@ import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Predicate;
import net.minecraft.core.BlockPos;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
@@ -108,11 +107,6 @@ public final class ShopData extends SavedData {
}
public Optional<DeliveryDestination> loadedDeliveryBox(MinecraftServer server, UUID owner) {
return loadedDeliveryBox(server, owner, level -> true);
}
public Optional<DeliveryDestination> loadedDeliveryBox(MinecraftServer server, UUID owner,
Predicate<ServerLevel> allowedLevel) {
String ownerId = owner.toString();
boolean changed = false;
Iterator<DeliveryAddress> iterator = deliveryBoxes.iterator();
@@ -123,7 +117,7 @@ public final class ShopData extends SavedData {
for (ServerLevel candidate : server.getAllLevels()) {
if (dimension(candidate).equals(address.dimension)) { level = candidate; break; }
}
if (level == null || allowedLevel == null || !allowedLevel.test(level)) continue;
if (level == null) continue;
BlockPos pos = BlockPos.of(address.pos);
if (!level.isLoaded(pos)) continue;
if (!(level.getBlockEntity(pos) instanceof DeliveryBoxBlockEntity box) || !owner.equals(box.owner())) {
@@ -26,7 +26,6 @@ public final class SanctuaryDimensions {
public static final ResourceKey<Level> PAINTED_CANYONS = dimension("painted_canyons");
public static final ResourceKey<Level> BREAKER_SEA = dimension("breaker_sea");
public static final ResourceKey<Level> LAPUTA = dimension("laputa");
public static final ResourceKey<Level> BACKROOM = dimension("backroom");
public static final ResourceKey<LevelStem> MAIN_STEM = stem("main");
public static final ResourceKey<LevelStem> OVERWORLD_STEM = stem("overworld");
@@ -38,12 +37,11 @@ public final class SanctuaryDimensions {
public static final ResourceKey<LevelStem> PAINTED_CANYONS_STEM = stem("painted_canyons");
public static final ResourceKey<LevelStem> BREAKER_SEA_STEM = stem("breaker_sea");
public static final ResourceKey<LevelStem> LAPUTA_STEM = stem("laputa");
public static final ResourceKey<LevelStem> BACKROOM_STEM = stem("backroom");
public static final Set<Identifier> RESERVED_IDS = Set.of(
MAIN.identifier(), OVERWORLD.identifier(), CAVERNS.identifier(), ALPHA.identifier(),
SHEEP_LAND.identifier(), SKELETON_WASTE.identifier(), LOST_CITY.identifier(),
PAINTED_CANYONS.identifier(), BREAKER_SEA.identifier(), LAPUTA.identifier(), BACKROOM.identifier()
PAINTED_CANYONS.identifier(), BREAKER_SEA.identifier(), LAPUTA.identifier()
);
private SanctuaryDimensions() {
@@ -5,7 +5,6 @@ import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.MatterDepthMaterializ
import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.AlphaIndevChunkFeature;
import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.SanctuaryWaterLakeFeature;
import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.SulfurCaveGeologyFeature;
import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.BackroomArchitectureFeature;
import net.fabricmc.fabric.api.biome.v1.BiomeModifications;
import net.fabricmc.fabric.api.biome.v1.BiomeSelectors;
import net.minecraft.core.Registry;
@@ -23,9 +22,6 @@ import net.minecraft.world.level.levelgen.placement.PlacedFeature;
/** Registers the two generator-specific features migrated from Sanctuary 26.1.2. */
public final class SanctuaryFeatures {
public static final Identifier BACKROOM_ARCHITECTURE_ID = SanctuaryMod.id("backroom_architecture");
public static final Feature<NoneFeatureConfiguration> BACKROOM_ARCHITECTURE =
new BackroomArchitectureFeature(NoneFeatureConfiguration.CODEC);
public static final Identifier ALPHA_INDEV_CHUNK_ID = SanctuaryMod.id("alpha_indev_chunk");
public static final Feature<NoneFeatureConfiguration> ALPHA_INDEV_CHUNK =
new AlphaIndevChunkFeature(NoneFeatureConfiguration.CODEC);
@@ -60,7 +56,6 @@ public final class SanctuaryFeatures {
}
public static void register() {
Registry.register(BuiltInRegistries.FEATURE, BACKROOM_ARCHITECTURE_ID, BACKROOM_ARCHITECTURE);
Registry.register(BuiltInRegistries.FEATURE, ALPHA_INDEV_CHUNK_ID, ALPHA_INDEV_CHUNK);
Registry.register(BuiltInRegistries.FEATURE,
MATTER_DEPTH_MATERIALIZATION_ID, MATTER_DEPTH_MATERIALIZATION);
@@ -1,498 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.worldgen.feature;
import com.mojang.serialization.Codec;
import fr.koka99cab.sanctuary26.sanctuary.api.backroom.BackroomMaterialApi;
import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.BackroomLayout.BackroomLevel;
import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.BackroomLayout.RoomCorner;
import java.util.Random;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.world.item.DyeColor;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.BedBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.LeavesBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BedPart;
import net.minecraft.world.level.levelgen.feature.Feature;
import net.minecraft.world.level.levelgen.feature.FeaturePlaceContext;
import net.minecraft.world.level.levelgen.feature.configurations.NoneFeatureConfiguration;
/**
* Five deterministic Backroom levels stacked from Y=384 to Y=-64. Generation is chunk-local,
* while every wall, doorway, highway and hole is derived from world coordinates so chunk seams
* remain open and coherent.
*/
public final class BackroomArchitectureFeature extends Feature<NoneFeatureConfiguration> {
public static final int PLAYABLE_RADIUS = 2_000;
private static final int CHUNK_SIZE = 16;
private static final int[] THRESHOLD_DECKS = {295, 313, 331, 349, 367};
private static final int[] ARCHIVE_DECKS = {206, 228, 250, 272};
private static final int[] UTILITY_DECKS = {28, 46, 64, 82, 100};
private static final BlockState CONCRETE_GRAY = Blocks.CONCRETE.pick(DyeColor.GRAY).defaultBlockState();
private static final BlockState CONCRETE_LIGHT = Blocks.CONCRETE.pick(DyeColor.LIGHT_GRAY).defaultBlockState();
private static final BlockState CONCRETE_WHITE = Blocks.CONCRETE.pick(DyeColor.WHITE).defaultBlockState();
private static final BlockState GLASS_BLACK = Blocks.STAINED_GLASS.pick(DyeColor.BLACK).defaultBlockState();
private static final BlockState GLASS_BROWN = Blocks.STAINED_GLASS.pick(DyeColor.BROWN).defaultBlockState();
private static final BlockState GLASS_CYAN = Blocks.STAINED_GLASS.pick(DyeColor.CYAN).defaultBlockState();
private static final BlockState CLAY_BROWN = Blocks.DYED_TERRACOTTA.pick(DyeColor.BROWN).defaultBlockState();
private static final BlockState CLAY_ORANGE = Blocks.DYED_TERRACOTTA.pick(DyeColor.ORANGE).defaultBlockState();
private static final BlockState CLAY_YELLOW = Blocks.DYED_TERRACOTTA.pick(DyeColor.YELLOW).defaultBlockState();
private static final BlockState LEAVES = Blocks.OAK_LEAVES.defaultBlockState().setValue(LeavesBlock.PERSISTENT, true);
private static final BlockState[] CORNER_MATERIALS = {
Blocks.BRICKS.defaultBlockState(),
Blocks.MUD_BRICKS.defaultBlockState(),
Blocks.STONE_BRICKS.defaultBlockState(),
CLAY_YELLOW,
Blocks.SMOOTH_SANDSTONE.defaultBlockState()
};
private static final BlockState[] GLITCH_MATERIALS = {
Blocks.BRICKS.defaultBlockState(),
Blocks.MUD_BRICKS.defaultBlockState(),
Blocks.STONE_BRICKS.defaultBlockState(),
Blocks.CRACKED_STONE_BRICKS.defaultBlockState(),
CLAY_BROWN
};
public BackroomArchitectureFeature(Codec<NoneFeatureConfiguration> codec) {
super(codec);
}
@Override
public boolean place(FeaturePlaceContext<NoneFeatureConfiguration> context) {
WorldGenLevel level = context.level();
int chunkX = Math.floorDiv(context.origin().getX(), CHUNK_SIZE);
int chunkZ = Math.floorDiv(context.origin().getZ(), CHUNK_SIZE);
int startX = chunkX * CHUNK_SIZE;
int startZ = chunkZ * CHUNK_SIZE;
if (!withinPlayableArea(startX + 8, startZ + 8)) return false;
long worldSeed = level.getSeed();
Random random = new Random(fingerprint(worldSeed, chunkX, chunkZ));
buildLevelShells(level, worldSeed, startX, startZ);
buildThreshold(level, worldSeed, startX, startZ);
buildArchives(level, worldSeed, startX, startZ);
buildCity(level, worldSeed, startX, startZ, chunkX, chunkZ);
buildUtilities(level, worldSeed, startX, startZ);
buildGlitchMaze(level, worldSeed, startX, startZ);
placeLootLandmarks(level, worldSeed, startX, startZ, chunkX, chunkZ);
placeRareAnomaly(level, startX, startZ, random);
return true;
}
public static boolean withinPlayableArea(int x, int z) {
return (long) x * x + (long) z * z <= (long) PLAYABLE_RADIUS * PLAYABLE_RADIUS;
}
/** Stable fingerprint used to prevent authored layout drift. */
public static long fingerprint(long worldSeed, int chunkX, int chunkZ) {
return mix(worldSeed ^ 0x4f1bbcdcL, chunkX, chunkZ);
}
private static void buildLevelShells(WorldGenLevel level, long seed, int x0, int z0) {
for (BackroomLevel zone : BackroomLevel.values()) {
for (int x = x0; x < x0 + CHUNK_SIZE; x++) for (int z = z0; z < z0 + CHUNK_SIZE; z++) {
if (!floorHole(seed, zone, x, z)) set(level, x, zone.floorY(), z, floorMaterial(zone));
if (!ceilingHole(seed, zone, x, z)) set(level, x, zone.ceilingY(), z, ceilingMaterial(zone));
}
}
}
private static boolean floorHole(long seed, BackroomLevel zone, int x, int z) {
return zone == BackroomLevel.LEVEL_4
? BackroomLayout.voidHole(seed, x, z)
: BackroomLayout.transitionHole(seed, zone.ordinal(), x, z);
}
private static boolean ceilingHole(long seed, BackroomLevel zone, int x, int z) {
return zone != BackroomLevel.LEVEL_0
&& BackroomLayout.transitionHole(seed, zone.ordinal() - 1, x, z);
}
private static BlockState floorMaterial(BackroomLevel zone) {
return switch (zone) {
case LEVEL_0 -> Blocks.BIRCH_PLANKS.defaultBlockState();
case LEVEL_1 -> Blocks.DARK_OAK_PLANKS.defaultBlockState();
case LEVEL_2 -> CONCRETE_GRAY;
case LEVEL_3 -> Blocks.POLISHED_ANDESITE.defaultBlockState();
case LEVEL_4 -> Blocks.STONE_BRICKS.defaultBlockState();
};
}
private static BlockState ceilingMaterial(BackroomLevel zone) {
return switch (zone) {
case LEVEL_0 -> CLAY_YELLOW;
case LEVEL_1 -> CONCRETE_LIGHT;
case LEVEL_2 -> CONCRETE_GRAY;
case LEVEL_3 -> Blocks.BRICKS.defaultBlockState();
case LEVEL_4 -> Blocks.MUD_BRICKS.defaultBlockState();
};
}
private static void buildThreshold(WorldGenLevel level, long seed, int x0, int z0) {
int cellX = Math.floorDiv(x0, CHUNK_SIZE);
int cellZ = Math.floorDiv(z0, CHUNK_SIZE);
for (int deck = 0; deck < THRESHOLD_DECKS.length; deck++) {
int floorY = THRESHOLD_DECKS[deck];
int roofY = deck + 1 < THRESHOLD_DECKS.length ? THRESHOLD_DECKS[deck + 1]
: BackroomLevel.LEVEL_0.ceilingY();
if (deck > 0) fillDeck(level, seed, floorY, x0, z0, Blocks.BIRCH_PLANKS.defaultBlockState());
int pattern = BackroomLayout.roomPattern(seed, BackroomLevel.LEVEL_0, cellX, cellZ, deck);
BlockState wall = pattern % 2 == 0 ? CLAY_YELLOW : Blocks.SMOOTH_SANDSTONE.defaultBlockState();
for (int x = x0; x < x0 + CHUNK_SIZE; x++) for (int z = z0; z < z0 + CHUNK_SIZE; z++) {
int localX = Math.floorMod(x, CHUNK_SIZE), localZ = Math.floorMod(z, CHUNK_SIZE);
boolean boundaryX = localX == 0, boundaryZ = localZ == 0;
if (!boundaryX && !boundaryZ) continue;
boolean doorway = boundaryX != boundaryZ && doorway(boundaryX ? localZ : localX, 7, 9);
if (doorway) continue;
for (int y = floorY + 1; y < roofY; y++) set(level, x, y, z, wall);
}
placeThresholdPattern(level, x0, floorY, z0, roofY, pattern, wall);
for (RoomCorner corner : RoomCorner.values()) {
placeCorner(level, seed, x0, floorY, z0, cellX, cellZ, deck, corner);
}
}
}
private static void placeThresholdPattern(WorldGenLevel level, int x0, int floorY, int z0,
int roofY, int pattern, BlockState wall) {
switch (pattern) {
case 1 -> partitionX(level, x0 + 5, floorY, roofY, z0 + 2, z0 + 13, z0 + 8, wall, false);
case 2 -> partitionZ(level, z0 + 10, floorY, roofY, x0 + 2, x0 + 13, x0 + 8, wall, false);
case 3 -> {
partitionX(level, x0 + 5, floorY, roofY, z0 + 2, z0 + 11, z0 + 7, wall, false);
partitionZ(level, z0 + 10, floorY, roofY, x0 + 5, x0 + 13, x0 + 10, wall, false);
}
case 4 -> partitionX(level, x0 + 8, floorY, roofY, z0 + 2, z0 + 13, z0 + 8, wall, true);
case 5 -> {
for (int x : new int[]{x0 + 5, x0 + 10}) for (int z : new int[]{z0 + 5, z0 + 10}) {
for (int y = floorY + 1; y <= Math.min(floorY + 6, roofY - 1); y++) {
set(level, x, y, z, y % 2 == 0 ? CLAY_ORANGE : CLAY_BROWN);
}
}
}
default -> { }
}
}
private static void placeCorner(WorldGenLevel level, long seed, int x0, int floorY, int z0,
int cellX, int cellZ, int deck, RoomCorner corner) {
int x = switch (corner) {
case NORTH_WEST, SOUTH_WEST -> x0 + 2;
case NORTH_EAST, SOUTH_EAST -> x0 + 13;
};
int z = switch (corner) {
case NORTH_WEST, NORTH_EAST -> z0 + 2;
case SOUTH_WEST, SOUTH_EAST -> z0 + 13;
};
BlockState material = CORNER_MATERIALS[BackroomLayout.cornerVariant(
seed ^ (long) deck * 31L, BackroomLevel.LEVEL_0, cellX, cellZ, corner)];
for (int y = floorY + 1; y <= floorY + 4; y++) set(level, x, y, z, material);
int towardX = corner == RoomCorner.NORTH_WEST || corner == RoomCorner.SOUTH_WEST ? 1 : -1;
int towardZ = corner == RoomCorner.NORTH_WEST || corner == RoomCorner.NORTH_EAST ? 1 : -1;
for (int distance = 1; distance <= 2; distance++) {
set(level, x + towardX * distance, floorY + 1, z, material);
set(level, x, floorY + 1, z + towardZ * distance, material);
}
}
private static void buildArchives(WorldGenLevel level, long seed, int x0, int z0) {
int macroX = Math.floorDiv(x0, 64), macroZ = Math.floorDiv(z0, 64);
for (int deck = 0; deck < ARCHIVE_DECKS.length; deck++) {
int floorY = ARCHIVE_DECKS[deck];
int roofY = deck + 1 < ARCHIVE_DECKS.length ? ARCHIVE_DECKS[deck + 1]
: BackroomLevel.LEVEL_1.ceilingY();
if (deck > 0) fillDeck(level, seed, floorY, x0, z0, Blocks.DARK_OAK_PLANKS.defaultBlockState());
boolean library = Math.floorMod((int) fingerprint(seed ^ deck, macroX, macroZ), 3) != 0;
for (int x = x0; x < x0 + CHUNK_SIZE; x++) for (int z = z0; z < z0 + CHUNK_SIZE; z++) {
int localX = Math.floorMod(x, 32), localZ = Math.floorMod(z, 32);
boolean wallX = localX == 0, wallZ = localZ == 0;
if (!wallX && !wallZ) continue;
boolean open = wallX != wallZ && doorway(wallX ? localZ : localX, 14, 18);
if (open) continue;
for (int y = floorY + 1; y < roofY; y++) {
BlockState state = y >= floorY + 5 && y <= floorY + 9 ? GLASS_BROWN : CONCRETE_LIGHT;
set(level, x, y, z, state);
}
}
if (library) buildLibraryRoom(level, x0, floorY, z0, roofY);
else buildOfficeRoom(level, seed, x0, floorY, z0, roofY, deck);
}
}
private static void buildLibraryRoom(WorldGenLevel level, int x0, int floorY, int z0, int roofY) {
for (int x = x0 + 1; x < x0 + 15; x++) for (int z = z0 + 1; z < z0 + 15; z++) {
int roomX = Math.floorMod(x, 32), roomZ = Math.floorMod(z, 32);
boolean shelf = (Math.floorMod(x, 8) == 3 || Math.floorMod(x, 8) == 4)
&& roomX > 3 && roomX < 28 && (roomZ < 12 || roomZ > 20);
if (!shelf) continue;
for (int y = floorY + 1; y <= Math.min(floorY + 6, roofY - 1); y++) {
set(level, x, y, z, Blocks.BOOKSHELF.defaultBlockState());
}
}
if (Math.floorMod(x0 + z0 + floorY, 3) == 0) {
for (int x = x0 + 6; x <= x0 + 9; x++) set(level, x, floorY + 1, z0 + 8,
Blocks.OAK_SLAB.defaultBlockState());
}
}
private static void buildOfficeRoom(WorldGenLevel level, long seed, int x0, int floorY, int z0,
int roofY, int deck) {
boolean alongX = (fingerprint(seed, Math.floorDiv(x0, 16), Math.floorDiv(z0, 16)) + deck & 1L) == 0L;
if (alongX) partitionX(level, x0 + 8, floorY, roofY, z0 + 1, z0 + 14, z0 + 8, CONCRETE_WHITE, true);
else partitionZ(level, z0 + 8, floorY, roofY, x0 + 1, x0 + 14, x0 + 8, CONCRETE_WHITE, true);
set(level, x0 + 4, floorY + 1, z0 + 4, Blocks.OAK_SLAB.defaultBlockState());
set(level, x0 + 5, floorY + 1, z0 + 4, Blocks.BOOKSHELF.defaultBlockState());
set(level, x0 + 11, floorY + 1, z0 + 11, Blocks.OAK_SLAB.defaultBlockState());
}
private static void buildCity(WorldGenLevel level, long seed, int x0, int z0, int chunkX, int chunkZ) {
boolean roadFree = true;
for (int x = x0; x < x0 + CHUNK_SIZE; x++) for (int z = z0; z < z0 + CHUNK_SIZE; z++) {
if (BackroomLayout.transitionHole(seed, BackroomLevel.LEVEL_2.ordinal(), x, z)) continue;
boolean highway = BackroomLayout.cityHighway(x, z);
if (highway) roadFree = false;
BlockState surface = highway ? CONCRETE_GRAY : CONCRETE_LIGHT;
if (highway && (Math.floorMod(x, 8) == 0 || Math.floorMod(z, 8) == 0)) surface = CONCRETE_WHITE;
set(level, x, BackroomLevel.LEVEL_2.floorY(), z, surface);
}
boolean park = roadFree && BackroomLayout.cityPark(seed, chunkX, chunkZ);
if (park) {
buildPark(level, seed, x0, z0);
return;
}
if (!roadFree) return;
Random random = new Random(fingerprint(seed ^ 0x514e7a2c39d8b60fL, chunkX, chunkZ));
int floorY = BackroomLevel.LEVEL_2.floorY();
int roofY = floorY + 24 + random.nextInt(56);
int minX = x0 + 1, maxX = x0 + 14, minZ = z0 + 1, maxZ = z0 + 14;
BlockState frame = random.nextBoolean() ? CONCRETE_LIGHT : Blocks.STONE_BRICKS.defaultBlockState();
BlockState glass = random.nextBoolean() ? GLASS_CYAN : GLASS_BLACK;
for (int y = floorY + 1; y <= roofY; y++) {
boolean slab = y == roofY || Math.floorMod(y - floorY, 7) == 0;
for (int x = minX; x <= maxX; x++) for (int z = minZ; z <= maxZ; z++) {
boolean edge = x == minX || x == maxX || z == minZ || z == maxZ;
boolean entrance = y <= floorY + 4 && (((x == minX || x == maxX) && doorway(z - z0, 7, 9))
|| ((z == minZ || z == maxZ) && doorway(x - x0, 7, 9)));
if (entrance) continue;
if (slab) set(level, x, y, z, frame);
else if (edge) set(level, x, y, z, Math.floorMod(y - floorY, 7) <= 2 ? frame : glass);
}
}
}
private static void buildPark(WorldGenLevel level, long seed, int x0, int z0) {
int floorY = BackroomLevel.LEVEL_2.floorY();
for (int x = x0 + 1; x < x0 + 15; x++) for (int z = z0 + 1; z < z0 + 15; z++) {
if (BackroomLayout.transitionHole(seed, BackroomLevel.LEVEL_2.ordinal(), x, z)) continue;
BlockState surface = x == x0 + 8 || z == z0 + 8
? Blocks.SMOOTH_STONE.defaultBlockState() : Blocks.GRASS_BLOCK.defaultBlockState();
set(level, x, floorY, z, surface);
}
Random random = new Random(fingerprint(seed ^ 0x1974acb65d203e8fL,
Math.floorDiv(x0, 16), Math.floorDiv(z0, 16)));
int trees = 1 + random.nextInt(2);
for (int index = 0; index < trees; index++) {
tree(level, x0 + 4 + random.nextInt(8), floorY + 1, z0 + 4 + random.nextInt(8));
}
}
private static void buildUtilities(WorldGenLevel level, long seed, int x0, int z0) {
for (int deck = 0; deck < UTILITY_DECKS.length; deck++) {
int floorY = UTILITY_DECKS[deck];
int roofY = deck + 1 < UTILITY_DECKS.length ? UTILITY_DECKS[deck + 1]
: BackroomLevel.LEVEL_3.ceilingY();
if (deck > 0) fillDeck(level, seed, floorY, x0, z0, Blocks.POLISHED_ANDESITE.defaultBlockState());
for (int x = x0; x < x0 + CHUNK_SIZE; x++) for (int z = z0; z < z0 + CHUNK_SIZE; z++) {
int localX = Math.floorMod(x, 24), localZ = Math.floorMod(z, 24);
boolean wallX = localX == 0, wallZ = localZ == 0;
if (wallX || wallZ) {
boolean open = wallX != wallZ && doorway(wallX ? localZ : localX, 10, 14);
if (!open) for (int y = floorY + 1; y < roofY; y++) {
BlockState state = y == floorY + 8 ? Blocks.IRON_BARS.defaultBlockState()
: (deck + Math.floorDiv(x + z, 24)) % 2 == 0
? Blocks.BRICKS.defaultBlockState() : Blocks.MUD_BRICKS.defaultBlockState();
set(level, x, y, z, state);
}
}
boolean overheadGrid = floorY + 12 < roofY
&& ((localX == 4 && localZ >= 7 && localZ <= 17)
|| (localZ == 4 && localX >= 7 && localX <= 17));
if (overheadGrid) set(level, x, floorY + 12, z, Blocks.IRON_BARS.defaultBlockState());
}
}
}
private static void buildGlitchMaze(WorldGenLevel level, long seed, int x0, int z0) {
for (int x = x0; x < x0 + CHUNK_SIZE; x++) for (int z = z0; z < z0 + CHUNK_SIZE; z++) {
if (!BackroomLayout.deckHole(seed, BackroomLayout.MAZE_FLOOR_Y, x, z)) {
set(level, x, BackroomLayout.MAZE_FLOOR_Y, z, Blocks.STONE_BRICKS.defaultBlockState());
}
if (!BackroomLayout.mazeRoofHole(seed, x, z)) {
set(level, x, BackroomLayout.MAZE_ROOF_Y, z, Blocks.MUD_BRICKS.defaultBlockState());
}
for (int y = BackroomLevel.LEVEL_4.floorY() + 1; y < BackroomLevel.LEVEL_4.ceilingY(); y++) {
if (y >= BackroomLayout.MAZE_FLOOR_Y && y <= BackroomLayout.MAZE_ROOF_Y) continue;
boolean edgeX = Math.floorMod(x, 8) == 0;
boolean edgeY = Math.floorMod(y - BackroomLevel.LEVEL_4.floorY(), 8) == 0;
boolean edgeZ = Math.floorMod(z, 8) == 0;
if ((edgeX && edgeY) || (edgeX && edgeZ) || (edgeY && edgeZ)) {
set(level, x, y, z, glitchMaterial(seed, x, y, z));
}
}
}
for (int x = x0; x < x0 + CHUNK_SIZE; x++) for (int z = z0; z < z0 + CHUNK_SIZE; z++) {
boolean wallX = Math.floorMod(x, CHUNK_SIZE) == 0;
boolean wallZ = Math.floorMod(z, CHUNK_SIZE) == 0;
if (!wallX && !wallZ) continue;
boolean open = false;
if (wallX != wallZ) {
if (wallX) {
int rightX = Math.floorDiv(x, CHUNK_SIZE), cellZ = Math.floorDiv(z, CHUNK_SIZE);
boolean connected = BackroomLayout.mazeConnected(seed, rightX - 1, cellZ, rightX, cellZ);
boolean merged = BackroomLayout.mergedMazeRooms(seed, rightX - 1, cellZ, rightX, cellZ);
open = merged || connected && doorway(Math.floorMod(z, CHUNK_SIZE), 7, 9);
} else {
int cellX = Math.floorDiv(x, CHUNK_SIZE), southZ = Math.floorDiv(z, CHUNK_SIZE);
boolean connected = BackroomLayout.mazeConnected(seed, cellX, southZ - 1, cellX, southZ);
boolean merged = BackroomLayout.mergedMazeRooms(seed, cellX, southZ - 1, cellX, southZ);
open = merged || connected && doorway(Math.floorMod(x, CHUNK_SIZE), 7, 9);
}
}
if (open) continue;
for (int y = BackroomLayout.MAZE_FLOOR_Y + 1; y < BackroomLayout.MAZE_ROOF_Y; y++) {
BlockState state = y >= BackroomLayout.MAZE_FLOOR_Y + 4
&& y <= BackroomLayout.MAZE_FLOOR_Y + 6 ? GLASS_BLACK : glitchMaterial(seed, x, y, z);
set(level, x, y, z, state);
}
}
int cellX = Math.floorDiv(x0, CHUNK_SIZE), cellZ = Math.floorDiv(z0, CHUNK_SIZE);
int pattern = BackroomLayout.roomPattern(seed, BackroomLevel.LEVEL_4, cellX, cellZ, 0);
BlockState wall = GLITCH_MATERIALS[Math.floorMod(pattern, GLITCH_MATERIALS.length)];
placeThresholdPattern(level, x0, BackroomLayout.MAZE_FLOOR_Y, z0,
BackroomLayout.MAZE_ROOF_Y, pattern, wall);
}
private static BlockState glitchMaterial(long seed, int x, int y, int z) {
int index = Math.floorMod((int) fingerprint(seed ^ y, Math.floorDiv(x, 8), Math.floorDiv(z, 8)),
GLITCH_MATERIALS.length);
return GLITCH_MATERIALS[index];
}
private static void fillDeck(WorldGenLevel level, long seed, int floorY, int x0, int z0,
BlockState state) {
for (int x = x0; x < x0 + CHUNK_SIZE; x++) for (int z = z0; z < z0 + CHUNK_SIZE; z++) {
if (!BackroomLayout.deckHole(seed, floorY, x, z)) set(level, x, floorY, z, state);
}
}
private static void partitionX(WorldGenLevel level, int x, int floorY, int roofY,
int minZ, int maxZ, int doorZ, BlockState wall, boolean window) {
for (int z = minZ; z <= maxZ; z++) {
if (Math.abs(z - doorZ) <= 1) continue;
for (int y = floorY + 1; y < roofY; y++) {
set(level, x, y, z, window && y >= floorY + 5 && y <= floorY + 8 ? GLASS_BROWN : wall);
}
}
}
private static void partitionZ(WorldGenLevel level, int z, int floorY, int roofY,
int minX, int maxX, int doorX, BlockState wall, boolean window) {
for (int x = minX; x <= maxX; x++) {
if (Math.abs(x - doorX) <= 1) continue;
for (int y = floorY + 1; y < roofY; y++) {
set(level, x, y, z, window && y >= floorY + 5 && y <= floorY + 8 ? GLASS_BROWN : wall);
}
}
}
private static boolean doorway(int coordinate, int minimum, int maximum) {
return coordinate >= minimum && coordinate <= maximum;
}
private static void placeLootLandmarks(WorldGenLevel level, long seed, int x0, int z0,
int chunkX, int chunkZ) {
for (BackroomLevel zone : BackroomLevel.values()) {
if (BackroomLayout.lootChestChunk(seed, zone, chunkX, chunkZ)) {
placeLootLandmark(level, zone, x0 + 7, z0 + 7);
}
}
for (BackroomLevel zone : BackroomLevel.values()) {
if (!BackroomLayout.exitBedChunk(seed, zone, chunkX, chunkZ)) continue;
int floorY = BackroomLayout.exitBedFloor(zone);
int x = x0 + 7, z = z0 + 8;
set(level, x, floorY, z, floorMaterial(zone));
set(level, x + 1, floorY, z, floorMaterial(zone));
for (int dx = 0; dx <= 1; dx++) for (int dy = 1; dy <= 2; dy++) {
set(level, x + dx, floorY + dy, z, Blocks.AIR.defaultBlockState());
}
placeBed(level, x, floorY + 1, z);
}
}
private static void placeLootLandmark(WorldGenLevel level, BackroomLevel zone, int x, int z) {
int floorY = BackroomLayout.exitBedFloor(zone);
for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++) {
set(level, x + dx, floorY, z + dz, floorMaterial(zone));
for (int dy = 1; dy <= 2; dy++) set(level, x + dx, floorY + dy, z + dz,
Blocks.AIR.defaultBlockState());
}
set(level, x, floorY + 1, z, Blocks.CHEST.defaultBlockState());
}
private static void placeRareAnomaly(WorldGenLevel level, int x0, int z0, Random random) {
if (random.nextInt(96) == 0) {
Block anomaly = BackroomMaterialApi.anomalyMaterial();
BackroomLevel zone = BackroomLevel.values()[random.nextInt(BackroomLevel.values().length)];
int floorY = BackroomLayout.exitBedFloor(zone);
for (int index = 0; index < 5; index++) {
set(level, x0 + 5 + random.nextInt(6), floorY + 1 + random.nextInt(4),
z0 + 5 + random.nextInt(6), anomaly.defaultBlockState());
}
}
if (random.nextInt(4_096) == 0
&& withinPlayableArea(x0 + 7, z0 + 7) && withinPlayableArea(x0 + 9, z0 + 9)) {
for (int dx = -1; dx <= 1; dx++) for (int dy = -1; dy <= 1; dy++) for (int dz = -1; dz <= 1; dz++) {
set(level, x0 + 8 + dx, -40 + dy, z0 + 8 + dz, Blocks.SMOOTH_STONE.defaultBlockState());
}
set(level, x0 + 8, -40, z0 + 8, Blocks.DIAMOND_BLOCK.defaultBlockState());
}
}
private static void placeBed(WorldGenLevel level, int x, int y, int z) {
if (!withinPlayableArea(x, z) || !withinPlayableArea(x + 1, z)) return;
Block bed = Blocks.BED.pick(DyeColor.WHITE);
BlockState foot = bed.defaultBlockState().setValue(BedBlock.FACING, Direction.EAST)
.setValue(BedBlock.PART, BedPart.FOOT);
BlockState head = foot.setValue(BedBlock.PART, BedPart.HEAD);
set(level, x, y, z, foot);
set(level, x + 1, y, z, head);
}
private static void tree(WorldGenLevel level, int x, int y, int z) {
for (int dy = 0; dy < 6; dy++) set(level, x, y + dy, z, Blocks.OAK_LOG.defaultBlockState());
for (int dx = -2; dx <= 2; dx++) for (int dz = -2; dz <= 2; dz++) for (int dy = 4; dy <= 7; dy++) {
if (Math.abs(dx) + Math.abs(dz) + Math.abs(dy - 6) <= 5) set(level, x + dx, y + dy, z + dz, LEAVES);
}
}
private static void set(WorldGenLevel level, int x, int y, int z, BlockState state) {
if (withinPlayableArea(x, z) && y >= level.getMinY() && y < level.getMaxY()) {
level.setBlock(new BlockPos(x, y, z), state, 2);
}
}
private static long mix(long seed, int x, int z) {
long value = seed ^ (long) x * 0x9E3779B97F4A7C15L ^ (long) z * 0xC2B2AE3D27D4EB4FL;
value ^= value >>> 30;
value *= 0xBF58476D1CE4E5B9L;
value ^= value >>> 27;
value *= 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -1,196 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.worldgen.feature;
import java.util.Objects;
/**
* Pure deterministic layout rules shared by Backroom generation, arrivals and executable tests.
* Level numbers run from the top down so a fall always moves toward the next numbered level.
*/
public final class BackroomLayout {
public static final int WORLD_MIN_Y = -64;
public static final int WORLD_MAX_Y_EXCLUSIVE = 384;
public static final int ENTRY_FLOOR_Y = 367;
public static final int ROOM_SIZE = 16;
public static final int MAZE_FLOOR_Y = 8;
public static final int MAZE_ROOF_Y = 18;
public static final int EXIT_BED_CELL_SIZE_CHUNKS = 3;
public static final int LOOT_CHEST_CELL_SIZE_CHUNKS = 3;
private static final int HOLE_CELL_SIZE = 64;
private BackroomLayout() {}
public static BackroomLevel levelAt(int y) {
for (BackroomLevel level : BackroomLevel.values()) {
if (y >= level.floorY && y <= level.ceilingY) return level;
}
throw new IllegalArgumentException("Y outside the Backroom: " + y);
}
/** A matching opening through an upper floor and the lower level's ceiling. */
public static boolean transitionHole(long seed, int transition, int x, int z) {
if (transition < 0 || transition >= BackroomLevel.values().length - 1) return false;
return authoredHole(seed ^ 0x2f53a71d4e6c890bL, transition, x, z, 1);
}
/** Sparse holes between decks create vertical routes without turning every room into a shaft. */
public static boolean deckHole(long seed, int floorY, int x, int z) {
return authoredHole(seed ^ 0x74c8e6219b0d35afL, floorY, x, z, 1);
}
/** The last floor deliberately has occasional exits into the actual void. */
public static boolean voidHole(long seed, int x, int z) {
return authoredHole(seed ^ 0x56de9c183af247b1L, 5, x, z, 2);
}
/** Openings in the maze roof let a player who landed on it enter the generated rooms. */
public static boolean mazeRoofHole(long seed, int x, int z) {
return authoredHole(seed ^ 0x6b8f1de425a390c7L, 6, x, z, 1);
}
public static int roomPattern(long seed, BackroomLevel level, int cellX, int cellZ, int deck) {
return Math.floorMod((int) mix(seed ^ 0x3c79ac492ba7b653L,
cellX + level.ordinal() * 1_009, cellZ + deck * 4_099), 6);
}
/** Each of the four authored corner directions independently reaches five material variants. */
public static int cornerVariant(long seed, BackroomLevel level, int cellX, int cellZ,
RoomCorner corner) {
Objects.requireNonNull(level, "level");
Objects.requireNonNull(corner, "corner");
return Math.floorMod((int) mix(seed ^ 0x1c69b3f74ac4ae35L,
cellX + corner.ordinal() * 8_191, cellZ + level.ordinal() * 16_381), 5);
}
/**
* Symmetric room-edge rule. Every maze cell has a parent one step nearer the origin, which makes
* the complete graph connected; extra deterministic edges introduce loops and glitches.
*/
public static boolean mazeConnected(long seed, int ax, int az, int bx, int bz) {
if (Math.abs(ax - bx) + Math.abs(az - bz) != 1) return false;
if (isParent(ax, az, bx, bz) || isParent(bx, bz, ax, az)) return true;
int minX = Math.min(ax, bx), minZ = Math.min(az, bz);
int edgeAxis = ax == bx ? 1 : 0;
return Math.floorMod((int) mix(seed ^ 0x5d42c713e9a86bf0L,
minX * 2 + edgeAxis, minZ * 2 + 1 - edgeAxis), 5) == 0;
}
/** Some connected cells completely lose their partition and become rooms at a larger scale. */
public static boolean mergedMazeRooms(long seed, int ax, int az, int bx, int bz) {
if (!mazeConnected(seed, ax, az, bx, bz)) return false;
int minX = Math.min(ax, bx), minZ = Math.min(az, bz);
int edgeAxis = ax == bx ? 1 : 0;
return Math.floorMod((int) mix(seed ^ 0x0b4e8f619c32d75aL,
minX * 2 + edgeAxis, minZ * 2 + 1 - edgeAxis), 11) == 0;
}
/** Infinite orthogonal highways, nine blocks wide and 96 blocks apart. */
public static boolean cityHighway(int x, int z) {
return centeredModulo(x - 8, 96) <= 4 || centeredModulo(z - 8, 96) <= 4;
}
public static boolean cityPark(long seed, int chunkX, int chunkZ) {
return Math.floorMod((int) mix(seed ^ 0x43c2a1769f0e85bdL, chunkX, chunkZ), 11) == 0;
}
/** One easy-to-find loot landmark per three-by-three chunk cell and per vertical level. */
public static boolean lootChestChunk(long seed, BackroomLevel level, int chunkX, int chunkZ) {
Objects.requireNonNull(level, "level");
int cellX = Math.floorDiv(chunkX, LOOT_CHEST_CELL_SIZE_CHUNKS);
int cellZ = Math.floorDiv(chunkZ, LOOT_CHEST_CELL_SIZE_CHUNKS);
long hash = mix(seed ^ 0x354c6f3a82d9e107L ^ (long) level.ordinal() * 0x9e3779b97f4a7c15L,
cellX, cellZ);
int targetX = cellX * LOOT_CHEST_CELL_SIZE_CHUNKS
+ Math.floorMod((int) hash, LOOT_CHEST_CELL_SIZE_CHUNKS);
int targetZ = cellZ * LOOT_CHEST_CELL_SIZE_CHUNKS
+ Math.floorMod((int) (hash >>> 32), LOOT_CHEST_CELL_SIZE_CHUNKS);
return chunkX == targetX && chunkZ == targetZ;
}
/** One deterministic exit-bed chunk per three-by-three grid cell and per vertical level. */
public static boolean exitBedChunk(long seed, BackroomLevel level, int chunkX, int chunkZ) {
Objects.requireNonNull(level, "level");
int cellX = Math.floorDiv(chunkX, EXIT_BED_CELL_SIZE_CHUNKS);
int cellZ = Math.floorDiv(chunkZ, EXIT_BED_CELL_SIZE_CHUNKS);
long hash = mix(seed ^ 0x6ac690c5d07f12e9L ^ (long) level.ordinal() * 0x9e3779b97f4a7c15L,
cellX, cellZ);
int targetX = cellX * EXIT_BED_CELL_SIZE_CHUNKS
+ Math.floorMod((int) hash, EXIT_BED_CELL_SIZE_CHUNKS);
int targetZ = cellZ * EXIT_BED_CELL_SIZE_CHUNKS
+ Math.floorMod((int) (hash >>> 32), EXIT_BED_CELL_SIZE_CHUNKS);
return chunkX == targetX && chunkZ == targetZ;
}
/** A traversable authored floor used for exit beds in each of the five levels. */
public static int exitBedFloor(BackroomLevel level) {
return switch (Objects.requireNonNull(level, "level")) {
case LEVEL_0 -> 349;
case LEVEL_1 -> 250;
case LEVEL_2 -> 117;
case LEVEL_3 -> 64;
case LEVEL_4 -> MAZE_FLOOR_Y;
};
}
private static boolean isParent(int childX, int childZ, int parentX, int parentZ) {
if (childX == 0 && childZ == 0) return false;
int expectedX = childX, expectedZ = childZ;
if (Math.abs(childX) >= Math.abs(childZ) && childX != 0) expectedX -= Integer.signum(childX);
else expectedZ -= Integer.signum(childZ);
return expectedX == parentX && expectedZ == parentZ;
}
private static boolean authoredHole(long seed, int salt, int x, int z, int radius) {
int cellX = Math.floorDiv(x, HOLE_CELL_SIZE);
int cellZ = Math.floorDiv(z, HOLE_CELL_SIZE);
long hash = mix(seed ^ (long) salt * 0x9e3779b97f4a7c15L, cellX, cellZ);
int centerX = cellX * HOLE_CELL_SIZE + 8 + Math.floorMod((int) hash, HOLE_CELL_SIZE - 16);
int centerZ = cellZ * HOLE_CELL_SIZE + 8
+ Math.floorMod((int) (hash >>> 32), HOLE_CELL_SIZE - 16);
return Math.abs(x - centerX) <= radius && Math.abs(z - centerZ) <= radius;
}
private static int centeredModulo(int value, int period) {
int wrapped = Math.floorMod(value + period / 2, period) - period / 2;
return Math.abs(wrapped);
}
private static long mix(long seed, int x, int z) {
long value = seed ^ (long) x * 0x9E3779B97F4A7C15L ^ (long) z * 0xC2B2AE3D27D4EB4FL;
value ^= value >>> 30;
value *= 0xBF58476D1CE4E5B9L;
value ^= value >>> 27;
value *= 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
public enum BackroomLevel {
LEVEL_0(295, 383),
LEVEL_1(206, 294),
LEVEL_2(117, 205),
LEVEL_3(28, 116),
LEVEL_4(-64, 27);
private final int floorY;
private final int ceilingY;
BackroomLevel(int floorY, int ceilingY) {
this.floorY = floorY;
this.ceilingY = ceilingY;
}
public int floorY() {
return this.floorY;
}
public int ceilingY() {
return this.ceilingY;
}
}
public enum RoomCorner {
NORTH_WEST,
NORTH_EAST,
SOUTH_WEST,
SOUTH_EAST
}
}
@@ -96,7 +96,6 @@
"message.sanctuary.quest_request.details.reward_legacy": "Its author must edit this legacy request and deposit the reward.",
"message.sanctuary.quest_request.details.progress": "Progress: %s/%s.",
"message.sanctuary.quest_request.details.completed": "Request completed · reward dispatched.",
"message.sanctuary.quest_request.details.completed_by": "Request completed by %s · reward dispatched.",
"message.sanctuary.quest_request.accept": "[ACCEPT QUEST]",
"message.sanctuary.quest_request.accept_hover": "Click to confirm after reading every detail.",
"message.sanctuary.quest_request.accept_prompt": "Read the details, then confirm in the acceptance screen.",
@@ -463,7 +462,6 @@
"sign.sanctuary.quest_request.available": "AVAILABLE",
"sign.sanctuary.quest_request.claimed": "TAKEN",
"sign.sanctuary.quest_request.completed": "COMPLETED",
"sign.sanctuary.quest_request.completed_by": "Completed by: %s",
"sign.sanctuary.quest_request.details": "Click: details",
"message.sanctuary.companion.active_locked": "Unlock the active companion power in Progression for %s XP levels.",
"message.sanctuary.shop.delivery_box_registered": "Mailbox registered at %s, %s, %s.",
@@ -517,27 +515,7 @@
"item.sanctuary.mojang_classic_cape": "Mojang Classic Cape",
"item.sanctuary.blue_scroll_cape": "Blue Scroll Cape",
"item.sanctuary.white_cape": "White Cape",
"tooltip.sanctuary.chicken_armor": "Can also equip and protect an adult chicken.",
"dimension.sanctuary.alpha": "Alpha / Indev Island",
"dimension.sanctuary.backroom": "Backroom",
"message.sanctuary.backroom.unavailable": "The Backroom is unavailable in this world.",
"message.sanctuary.backroom.pending": "Your previous Backroom transfer is waiting for your Mailbox to be loaded.",
"message.sanctuary.backroom.registry_busy": "The Backroom registry is settling too many interrupted transfers. Try again later.",
"message.sanctuary.backroom.mailbox_missing": "Load and register your Mailbox before entering the Backroom.",
"message.sanctuary.backroom.mailbox_full": "Your Mailbox has no room for all your gear, companion egg and cape.",
"message.sanctuary.backroom.entered": "You slip through the bed with no equipment… A torch marks your arrival point.",
"message.sanctuary.backroom.bed_entry_only": "Only a bed can take you into the Backroom.",
"message.sanctuary.backroom.exited": "The bed returns you to your spawn point with your findings.",
"message.sanctuary.backroom.exited_with_losses": "The bed returns you with your findings and settles the recovered lost items.",
"screen.sanctuary.atlas.landmark.boss": "Defeated boss",
"message.sanctuary.notch_defeated": "Notch Titan has been defeated. The victory is now recorded in this world.",
"metric.sanctuary.ranking.play_time": "Play time",
"metric.sanctuary.ranking.mined": "%s mined",
"metric.sanctuary.ranking.crafted": "%s crafted",
"metric.sanctuary.ranking.used": "%s used",
"metric.sanctuary.ranking.broken": "%s broken",
"metric.sanctuary.ranking.picked_up": "%s picked up",
"metric.sanctuary.ranking.dropped": "%s dropped",
"metric.sanctuary.ranking.custom": "Custom statistic",
"metric.sanctuary.ranking.unknown": "Unknown statistic"
"message.sanctuary.notch_defeated": "Notch Titan has been defeated. The victory is now recorded in this world."
}
@@ -96,7 +96,6 @@
"message.sanctuary.quest_request.details.reward_legacy": "Cette ancienne demande doit être modifiée par son auteur pour déposer sa récompense.",
"message.sanctuary.quest_request.details.progress": "Progression : %s/%s.",
"message.sanctuary.quest_request.details.completed": "Demande terminée · récompense expédiée.",
"message.sanctuary.quest_request.details.completed_by": "Demande complétée par %s · récompense expédiée.",
"message.sanctuary.quest_request.accept": "[ACCEPTER LA QUÊTE]",
"message.sanctuary.quest_request.accept_hover": "Clique pour confirmer après avoir lu tous les détails.",
"message.sanctuary.quest_request.accept_prompt": "Lis les détails puis confirme dans la fenêtre d'acceptation.",
@@ -463,7 +462,6 @@
"sign.sanctuary.quest_request.available": "DISPONIBLE",
"sign.sanctuary.quest_request.claimed": "PRISE",
"sign.sanctuary.quest_request.completed": "TERMINÉE",
"sign.sanctuary.quest_request.completed_by": "Complétée par : %s",
"sign.sanctuary.quest_request.details": "Clic : détails",
"message.sanctuary.companion.active_locked": "Débloquez le pouvoir actif du familier dans Progression pour %s niveaux d'XP.",
"message.sanctuary.shop.delivery_box_registered": "Boîte aux lettres enregistrée en %s, %s, %s.",
@@ -517,27 +515,7 @@
"item.sanctuary.mojang_classic_cape": "Cape Mojang classique",
"item.sanctuary.blue_scroll_cape": "Cape parchemin bleu",
"item.sanctuary.white_cape": "Cape blanche",
"tooltip.sanctuary.chicken_armor": "Peut aussi équiper et protéger une poule adulte.",
"dimension.sanctuary.alpha": "Île Alpha / Indev",
"dimension.sanctuary.backroom": "Backroom",
"message.sanctuary.backroom.unavailable": "La Backroom n’est pas disponible dans ce monde.",
"message.sanctuary.backroom.pending": "Ton ancien transfert Backroom attend que ta Mailbox soit chargée.",
"message.sanctuary.backroom.registry_busy": "Le registre Backroom termine trop de transferts interrompus. Réessaie plus tard.",
"message.sanctuary.backroom.mailbox_missing": "Charge et enregistre ta Mailbox avant d’entrer dans la Backroom.",
"message.sanctuary.backroom.mailbox_full": "Ta Mailbox n’a pas assez de place pour tout ton équipement, l’œuf et la cape.",
"message.sanctuary.backroom.entered": "Tu traverses le lit sans aucun équipement… Une torche marque ton point d’arrivée.",
"message.sanctuary.backroom.bed_entry_only": "Seul un lit permet d’entrer dans la Backroom.",
"message.sanctuary.backroom.exited": "Le lit te ramène à ton point d’apparition avec tes trouvailles.",
"message.sanctuary.backroom.exited_with_losses": "Le lit te ramène avec tes trouvailles et valide les objets perdus récupérés.",
"screen.sanctuary.atlas.landmark.boss": "Boss vaincu",
"message.sanctuary.notch_defeated": "Notch Titan a été vaincu. La victoire est désormais inscrite dans ce monde.",
"metric.sanctuary.ranking.play_time": "Temps de jeu",
"metric.sanctuary.ranking.mined": "%s extrait",
"metric.sanctuary.ranking.crafted": "%s fabriqué",
"metric.sanctuary.ranking.used": "%s utilisé",
"metric.sanctuary.ranking.broken": "%s cassé",
"metric.sanctuary.ranking.picked_up": "%s ramassé",
"metric.sanctuary.ranking.dropped": "%s jeté",
"metric.sanctuary.ranking.custom": "Statistique personnalisée",
"metric.sanctuary.ranking.unknown": "Statistique inconnue"
"message.sanctuary.notch_defeated": "Notch Titan a été vaincu. La victoire est désormais inscrite dans ce monde."
}
@@ -374,7 +374,6 @@
"sign.sanctuary.quest_request.available": "СВОБОДНО",
"sign.sanctuary.quest_request.claimed": "ЗАНЯТО",
"sign.sanctuary.quest_request.completed": "ВЫПОЛНЕНО",
"sign.sanctuary.quest_request.completed_by": "Выполнил: %s",
"sign.sanctuary.quest_request.details": "ПКМ: детали",
"message.sanctuary.quest_request.occupied": "На этой табличке уже есть запрос.",
"message.sanctuary.quest_request.protected": "Только заказчик или оператор может забрать эту табличку.",
@@ -391,7 +390,6 @@
"message.sanctuary.quest_request.details.reward_legacy": "Автор должен изменить этот старый запрос и внести награду.",
"message.sanctuary.quest_request.details.progress": "Прогресс: %s/%s.",
"message.sanctuary.quest_request.details.completed": "Запрос выполнен · награда отправлена.",
"message.sanctuary.quest_request.details.completed_by": "Запрос выполнил %s · награда отправлена.",
"message.sanctuary.quest_request.accept": "[ПРИНЯТЬ ЗАДАНИЕ]",
"message.sanctuary.quest_request.accept_hover": "Нажмите, чтобы подтвердить после чтения всех подробностей.",
"message.sanctuary.quest_request.accept_prompt": "Прочитайте подробности и подтвердите принятие в открывшемся окне.",
@@ -473,27 +471,7 @@
"item.sanctuary.mojang_classic_cape": "Классический плащ Mojang",
"item.sanctuary.blue_scroll_cape": "Плащ синего свитка",
"item.sanctuary.white_cape": "Белый плащ",
"tooltip.sanctuary.chicken_armor": "Также надевается на взрослую курицу и защищает её.",
"dimension.sanctuary.alpha": "Остров Альфа / Indev",
"dimension.sanctuary.backroom": "Бэкрум",
"message.sanctuary.backroom.unavailable": "Бэкрум недоступен в этом мире.",
"message.sanctuary.backroom.pending": "Предыдущий перенос в Бэкрум ожидает загрузки вашего почтового ящика.",
"message.sanctuary.backroom.registry_busy": "Реестр Бэкрума завершает слишком много прерванных переносов. Повторите попытку позже.",
"message.sanctuary.backroom.mailbox_missing": "Загрузите и зарегистрируйте почтовый ящик перед входом в Бэкрум.",
"message.sanctuary.backroom.mailbox_full": "В почтовом ящике недостаточно места для снаряжения, яйца спутника и плаща.",
"message.sanctuary.backroom.entered": "Вы проваливаетесь сквозь кровать без снаряжения… Факел отмечает место прибытия.",
"message.sanctuary.backroom.bed_entry_only": "В Бэкрум можно попасть только через кровать.",
"message.sanctuary.backroom.exited": "Кровать возвращает вас к точке возрождения со всеми находками.",
"message.sanctuary.backroom.exited_with_losses": "Кровать возвращает вас с находками и закрепляет найденные потерянные предметы.",
"screen.sanctuary.atlas.landmark.boss": "Побеждённый босс",
"message.sanctuary.notch_defeated": "Нотч-Титан побеждён. Победа навсегда записана в этом мире.",
"metric.sanctuary.ranking.play_time": "Время в игре",
"metric.sanctuary.ranking.mined": "Добыто: %s",
"metric.sanctuary.ranking.crafted": "Создано: %s",
"metric.sanctuary.ranking.used": "Использовано: %s",
"metric.sanctuary.ranking.broken": "Сломано: %s",
"metric.sanctuary.ranking.picked_up": "Подобрано: %s",
"metric.sanctuary.ranking.dropped": "Выброшено: %s",
"metric.sanctuary.ranking.custom": "Пользовательская статистика",
"metric.sanctuary.ranking.unknown": "Неизвестная статистика"
"message.sanctuary.notch_defeated": "Нотч-Титан побеждён. Победа навсегда записана в этом мире."
}
@@ -22,14 +22,6 @@
"settings": "minecraft:overworld"
}
},
"sanctuary:backroom": {
"type": "sanctuary:backroom",
"generator": {
"type": "minecraft:noise",
"biome_source": { "type": "minecraft:fixed", "biome": "sanctuary:backroom" },
"settings": "sanctuary:backroom"
}
},
"minecraft:the_end": {
"type": "minecraft:the_end",
"generator": {
@@ -1,11 +0,0 @@
{
"type": "sanctuary:backroom",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:fixed",
"biome": "sanctuary:backroom"
},
"settings": "sanctuary:backroom"
}
}
@@ -1,41 +0,0 @@
{
"ambient_light": 0.0,
"attributes": {
"minecraft:audio/ambient_sounds": {
"mood": {
"block_search_extent": 8,
"offset": 2.0,
"sound": "minecraft:ambient.cave",
"tick_delay": 5000
}
},
"minecraft:gameplay/bed_rule": {
"can_set_spawn": "never",
"can_sleep": "never"
},
"minecraft:gameplay/can_start_raid": false,
"minecraft:gameplay/respawn_anchor_works": false,
"minecraft:gameplay/sky_light_level": 0.0,
"minecraft:gameplay/water_evaporates": false,
"minecraft:visual/ambient_light_color": "#000000",
"minecraft:visual/fog_color": "#000000",
"minecraft:visual/fog_end_distance": 96.0,
"minecraft:visual/fog_start_distance": 12.0,
"minecraft:visual/sky_light_color": "#000000",
"minecraft:visual/sky_light_factor": 0.0
},
"cardinal_light": "nether",
"coordinate_scale": 1.0,
"has_ceiling": true,
"has_ender_dragon_fight": false,
"has_fixed_time": true,
"has_skylight": false,
"height": 448,
"infiniburn": "#minecraft:infiniburn_overworld",
"logical_height": 448,
"min_y": -64,
"monster_spawn_block_light_limit": 0,
"monster_spawn_light_level": 0,
"skybox": "none",
"timelines": "#minecraft:in_nether"
}
@@ -1,28 +0,0 @@
{
"temperature": 0.65,
"downfall": 0.0,
"has_precipitation": false,
"attributes": {},
"effects": {
"water_color": "#596f75",
"water_fog_color": "#303b3e",
"fog_color": "#000000",
"sky_color": "#000000",
"grass_color": "#70845d",
"foliage_color": "#657b58"
},
"creature_spawn_probability": 0.0,
"spawners": {
"ambient": [],
"axolotls": [],
"creature": [],
"misc": [],
"monster": [],
"underground_water_creature": [],
"water_ambient": [],
"water_creature": []
},
"spawn_costs": {},
"carvers": [],
"features": [[], [], [], [], ["sanctuary:backroom_architecture"], [], [], [], [], [], []]
}
@@ -1,4 +0,0 @@
{
"type": "sanctuary:backroom_architecture",
"config": {}
}
@@ -1,37 +0,0 @@
{
"aquifers_enabled": false,
"default_block": { "Name": "minecraft:air" },
"default_fluid": { "Name": "minecraft:air" },
"disable_mob_generation": true,
"legacy_random_source": false,
"noise": {
"height": 448,
"min_y": -64,
"size_horizontal": 1,
"size_vertical": 2
},
"noise_router": {
"barrier": 0.0,
"continents": 0.0,
"depth": 0.0,
"erosion": 0.0,
"final_density": -1.0,
"fluid_level_floodedness": 0.0,
"fluid_level_spread": 0.0,
"lava": 0.0,
"preliminary_surface_level": 0.0,
"ridges": 0.0,
"temperature": 0.0,
"vegetation": 0.0,
"vein_gap": 0.0,
"vein_ridged": 0.0,
"vein_toggle": 0.0
},
"ore_veins_enabled": false,
"sea_level": 63,
"spawn_target": [],
"surface_rule": {
"type": "minecraft:block",
"result_state": { "Name": "minecraft:air" }
}
}
@@ -1,7 +0,0 @@
{
"feature": "sanctuary:backroom_architecture",
"placement": [
{ "type": "minecraft:count", "count": 1 },
{ "type": "minecraft:biome" }
]
}
@@ -58,14 +58,6 @@
"settings": "sanctuary:alpha"
}
},
"sanctuary:backroom": {
"type": "sanctuary:backroom",
"generator": {
"type": "minecraft:noise",
"biome_source": { "type": "minecraft:fixed", "biome": "sanctuary:backroom" },
"settings": "sanctuary:backroom"
}
},
"minecraft:the_end": {
"type": "minecraft:the_end",
"generator": {
@@ -58,14 +58,6 @@
"settings": "sanctuary:alpha"
}
},
"sanctuary:backroom": {
"type": "sanctuary:backroom",
"generator": {
"type": "minecraft:noise",
"biome_source": { "type": "minecraft:fixed", "biome": "sanctuary:backroom" },
"settings": "sanctuary:backroom"
}
},
"minecraft:the_end": {
"type": "minecraft:the_end",
"generator": {
+1 -1
View File
@@ -32,7 +32,7 @@
"sanctuary26": {
"system_type": "gameplay-system",
"namespace": "sanctuary",
"data_version": 15,
"data_version": 13,
"network_protocol": 24,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
@@ -4,23 +4,17 @@
"compatibilityLevel": "JAVA_25",
"mixins": [
"AbstractContainerMenuMixin",
"AnimalChickenArmorMixin",
"BedBlockMixin",
"ChestBlockEntityMixin",
"ConsumableMixin",
"EntityCompanionMixin",
"EntityAirSupplyMixin",
"FoodDataMixin",
"InventoryMixin",
"InventoryMenuMixin",
"ItemEntityMixin",
"ItemTooltipMixin",
"LivingEntityCompanionMixin",
"LivingEntityChickenArmorMixin",
"PlayerOverflowMixin",
"SlotMixin",
"ServerGamePacketListenerMixin",
"ServerPlayerBackroomMixin"
"ServerGamePacketListenerMixin"
],
"client": [
"client.AbstractButtonMixin",
@@ -30,8 +24,6 @@
"client.AbstractContainerScreenMixin",
"client.AbstractRecipeBookScreenMixin",
"client.CameraMixin",
"client.ChickenRendererMixin",
"client.ChickenRenderStateMixin",
"client.CreativeModeInventoryScreenMixin",
"client.CreativeSlotWrapperAccessor",
"client.CreateWorldScreenWorldTabMixin",
@@ -1,83 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import net.minecraft.SharedConstants;
import net.minecraft.resources.Identifier;
import net.minecraft.server.Bootstrap;
import net.minecraft.stats.Stats;
/** Pure ranking smoke for ordering, tied ranks, saturation and newer-save protection. */
public final class SanctuaryRankingModelSmoke {
private SanctuaryRankingModelSmoke() {}
public static void main(String[] args) throws Exception {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
Identifier metricId = Identifier.fromNamespaceAndPath("onlyfun", "opened_lootboxes");
RankingMetric metric = new RankingMetric(metricId,
"metric.onlyfun.ranking.opened_lootboxes", RankingUnit.COUNT);
UUID alex = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID bob = UUID.fromString("00000000-0000-0000-0000-000000000002");
UUID chris = UUID.fromString("00000000-0000-0000-0000-000000000003");
SanctuaryRankingData data = new SanctuaryRankingData();
data.recordValue(alex, "Alex", metricId, 10L);
data.recordValue(bob, "Bob", metricId, 10L);
data.recordValue(chris, "Chris", metricId, 5L);
RankingSnapshot tied = data.snapshot(metric, bob);
require(tied.podium().size() == 3, "Podium must remain bounded to available top three");
require(tied.podium().get(0).playerId().equals(alex),
"Earlier reached order must deterministically break a visual tie");
require(tied.podium().get(0).rank() == 1 && tied.podium().get(1).rank() == 1
&& tied.podium().get(2).rank() == 3, "Equal values must retain the same competition rank");
require(tied.owner().rank() == 1 && tied.owner().value() == 10L,
"Panel owner placement must reflect the real tied rank");
data.increment(chris, "Chris", metricId, Long.MAX_VALUE);
require(data.score(chris, metricId) == SanctuaryRankingData.MAX_VALUE,
"Public counters must saturate at the bounded long maximum");
Identifier usedStick = VanillaRankingMetric.id(Identifier.withDefaultNamespace("used"),
Identifier.withDefaultNamespace("stick"));
data.observeVanilla(alex, "Alex", usedStick, 100L, false);
data.observeVanilla(alex, "Alex", usedStick, 110L, false);
data.observeVanilla(alex, "Alex", usedStick, 115L, true);
require(data.score(alex, usedStick) == 5L,
"Creative or simulated vanilla deltas leaked into the public score");
data.observeVanilla(bob, "Bob", usedStick, 7L, true);
require(data.score(bob, usedStick) == 7L,
"A survival player's first observed vanilla statistics were discarded");
Identifier vanillaId = VanillaRankingMetric.id(
Identifier.withDefaultNamespace("custom"), Stats.PLAY_TIME);
require(VanillaRankingMetric.parse(vanillaId).orElseThrow().target().equals(Stats.PLAY_TIME)
&& VanillaRankingMetric.descriptor(vanillaId).unit() == RankingUnit.TICKS,
"Vanilla metric IDs and units must round-trip stably");
Path temporary = Files.createTempDirectory("sanctuary-ranking-migration-");
Path oldStats = temporary.resolve(alex + ".json");
Files.writeString(oldStats,
"{\"stats\":{\"minecraft:used\":{\"minecraft:stick\":12}}}", StandardCharsets.UTF_8);
SanctuaryRankingData imported = new SanctuaryRankingData();
imported.importVanillaFile(oldStats);
imported.importVanillaFile(oldStats);
require(imported.score(alex, usedStick) == 12L,
"Existing vanilla statistics were not imported monotonically exactly once");
Files.deleteIfExists(oldStats);
Files.deleteIfExists(temporary);
SanctuaryRankingData newer = new SanctuaryRankingData(
SanctuaryRankingData.DATA_VERSION + 1, true, 1L, Map.of());
newer.recordValue(alex, "Alex", metricId, 42L);
require(!newer.writable() && newer.score(alex, metricId) == 0L,
"A newer save must never be rewritten blindly");
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -1,189 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.backroom;
import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.BackroomArchitectureFeature;
import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.BackroomLayout;
import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.BackroomLayout.BackroomLevel;
import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.BackroomLayout.RoomCorner;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.util.RandomSource;
/** Executable invariants for bounded entry data, personal loot priority and architecture zoning. */
public final class BackroomModelSmoke {
private BackroomModelSmoke() {}
public static void main(String[] args) {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
entryTransactionsAreBounded();
personalRecoveryIsPrioritized();
recentLossesRemainPersonal();
architectureIsDeterministicAndStacked();
lootLandmarksCoverEveryLevel();
exitBedsCoverEveryLevel();
roomCornersReachEveryVariant();
mazeDoorsStayConnected();
}
private static void entryTransactionsAreBounded() {
BackroomData data = new BackroomData();
UUID player = UUID.fromString("c6ff53b1-8b3e-4d7d-9a46-81ef366b02ba");
require(BackroomData.MAX_ENTRY_SLOTS == 64, "Entry snapshot bound no longer covers exactly one player inventory");
BackroomData.PendingEntry first = data.prepareEntry(player, List.of()).orElseThrow();
BackroomData.PendingEntry replacement = data.prepareEntry(player, List.of()).orElseThrow();
require(!first.transaction().equals(replacement.transaction()), "Reprepared entry reused its transaction ID");
require(data.pendingEntry(player).orElseThrow().slots().isEmpty(),
"A player has more than one pending entry transaction");
data.finishEntry(player);
require(data.pendingEntry(player).isEmpty(), "Finished entry transaction remained persisted");
for (int index = 0; index < BackroomData.MAX_PENDING_ENTRIES; index++) {
require(data.prepareEntry(new UUID(0L, index + 1L), List.of()).isPresent(),
"Pending-entry registry filled before its documented bound");
}
require(data.prepareEntry(new UUID(1L, 1L), List.of()).isEmpty(),
"A new entry evicted an unresolved Mailbox transaction");
for (int index = 0; index < BackroomData.MAX_RECENT_ARRIVALS + 12; index++) {
data.beginExpedition(player, index);
}
require(data.recentArrivals(player).size() == BackroomData.MAX_RECENT_ARRIVALS,
"Arrival history exceeded its persistence bound");
}
private static void personalRecoveryIsPrioritized() {
RandomSource first = RandomSource.create(26_200_132L);
RandomSource second = RandomSource.create(26_200_133L);
RandomSource later = RandomSource.create(26_200_134L);
int firstHits = 0, secondHits = 0, laterHits = 0;
for (int sample = 0; sample < 20_000; sample++) {
if (BackroomChestService.personalRoll(first, 1)) firstHits++;
if (BackroomChestService.personalRoll(second, 2)) secondHits++;
if (!BackroomChestService.personalRoll(second, 3)) throw new AssertionError("Third personal chest is not guaranteed");
if (BackroomChestService.personalRoll(later, 4)) laterHits++;
}
require(firstHits > 14_500 && firstHits < 15_500, "First-chest personal chance drifted from 75%");
require(secondHits > 17_500 && secondHits < 18_500, "Second-chest personal chance drifted from 90%");
require(laterHits > 12_500 && laterHits < 13_500, "Later personal chance drifted from 65%");
require(BackroomChestService.REFRESH_TICKS == 12_000L, "Chest renewal is no longer twice per Minecraft day");
}
private static void recentLossesRemainPersonal() {
require(!BackroomData.sharedEligible("owner", 10_000L,
10_000L + BackroomData.PERSONAL_RESERVATION_TICKS - 1L),
"A recent owned loss escaped into historical loot");
require(BackroomData.sharedEligible("owner", 10_000L,
10_000L + BackroomData.PERSONAL_RESERVATION_TICKS),
"An old owned loss never became historical loot");
require(BackroomData.sharedEligible("", 10_000L, 10_000L),
"An ownerless loss was not immediately eligible for shared loot");
}
private static void architectureIsDeterministicAndStacked() {
long seed = 26_200_132L;
require(BackroomLayout.WORLD_MIN_Y == -64 && BackroomLayout.WORLD_MAX_Y_EXCLUSIVE == 384,
"Backroom no longer occupies the complete requested vertical range");
for (int y = BackroomLayout.WORLD_MIN_Y; y < BackroomLayout.WORLD_MAX_Y_EXCLUSIVE; y++) {
require(BackroomLayout.levelAt(y) != null, "A Y layer is not assigned to one of the five levels");
}
require(BackroomLevel.values().length == 5, "Backroom no longer has exactly five stacked levels");
require(BackroomLayout.levelAt(BackroomLayout.ENTRY_FLOOR_Y) == BackroomLevel.LEVEL_0,
"Arrival deck left the top level");
for (int transition = 0; transition < 4; transition++) {
boolean found = false;
for (int x = -64; x < 64 && !found; x++) for (int z = -64; z < 64; z++) {
if (BackroomLayout.transitionHole(seed, transition, x, z)) {
found = true;
break;
}
}
require(found, "One level transition has no reachable authored opening");
}
require(BackroomArchitectureFeature.fingerprint(seed, 12, -7)
== BackroomArchitectureFeature.fingerprint(seed, 12, -7),
"Architecture fingerprint is not deterministic");
require(BackroomLayout.cityHighway(8, 1_423) && !BackroomLayout.cityHighway(48, 48),
"City highways are no longer long continuous axes separated from building cells");
require(BackroomArchitectureFeature.withinPlayableArea(2_000, 0), "Playable boundary moved inward");
require(!BackroomArchitectureFeature.withinPlayableArea(2_001, 0), "Architecture escaped the 2,000-block zone");
}
private static void roomCornersReachEveryVariant() {
long seed = 26_200_132L;
Map<RoomCorner, Set<Integer>> variants = new EnumMap<>(RoomCorner.class);
for (RoomCorner corner : RoomCorner.values()) variants.put(corner, new HashSet<>());
for (int x = -32; x <= 32; x++) for (int z = -32; z <= 32; z++) {
for (RoomCorner corner : RoomCorner.values()) {
variants.get(corner).add(BackroomLayout.cornerVariant(seed, BackroomLevel.LEVEL_0, x, z, corner));
}
}
for (RoomCorner corner : RoomCorner.values()) {
require(variants.get(corner).equals(Set.of(0, 1, 2, 3, 4)),
"A room corner lost one of its five authored material variants: " + corner);
}
}
private static void exitBedsCoverEveryLevel() {
long seed = 26_200_133L;
int cellSize = BackroomLayout.EXIT_BED_CELL_SIZE_CHUNKS;
require(cellSize == 3, "Backroom exit beds are no longer spaced three chunks apart");
for (BackroomLevel level : BackroomLevel.values()) {
int floorY = BackroomLayout.exitBedFloor(level);
require(BackroomLayout.levelAt(floorY) == level,
"An exit-bed floor escaped its Backroom level: " + level);
for (int cellX = -2; cellX <= 2; cellX++) for (int cellZ = -2; cellZ <= 2; cellZ++) {
int candidates = 0;
for (int chunkX = cellX * cellSize; chunkX < (cellX + 1) * cellSize; chunkX++) {
for (int chunkZ = cellZ * cellSize; chunkZ < (cellZ + 1) * cellSize; chunkZ++) {
if (BackroomLayout.exitBedChunk(seed, level, chunkX, chunkZ)) candidates++;
}
}
require(candidates == 1,
"A Backroom level lost its exit bed in a three-by-three chunk cell: " + level);
}
}
}
private static void lootLandmarksCoverEveryLevel() {
long seed = 26_200_137L;
int cellSize = BackroomLayout.LOOT_CHEST_CELL_SIZE_CHUNKS;
require(cellSize == 3, "Backroom loot landmarks are no longer spaced three chunks apart");
for (BackroomLevel level : BackroomLevel.values()) {
for (int cellX = -4; cellX <= 4; cellX++) for (int cellZ = -4; cellZ <= 4; cellZ++) {
int candidates = 0;
for (int chunkX = cellX * cellSize; chunkX < (cellX + 1) * cellSize; chunkX++) {
for (int chunkZ = cellZ * cellSize; chunkZ < (cellZ + 1) * cellSize; chunkZ++) {
if (BackroomLayout.lootChestChunk(seed, level, chunkX, chunkZ)) candidates++;
}
}
require(candidates == 1,
"A Backroom level lost its loot landmark in a three-by-three chunk cell: " + level);
}
}
}
private static void mazeDoorsStayConnected() {
long seed = 26_200_132L;
for (int x = -16; x <= 16; x++) for (int z = -16; z <= 16; z++) {
if (x == 0 && z == 0) continue;
boolean descends = false;
for (int[] direction : new int[][]{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) {
int neighborX = x + direction[0], neighborZ = z + direction[1];
boolean forward = BackroomLayout.mazeConnected(seed, x, z, neighborX, neighborZ);
boolean reverse = BackroomLayout.mazeConnected(seed, neighborX, neighborZ, x, z);
require(forward == reverse, "Maze doorway is not symmetric across a room boundary");
if (forward && Math.abs(neighborX) + Math.abs(neighborZ) < Math.abs(x) + Math.abs(z)) descends = true;
}
require(descends, "A generated maze room has no open route toward the connected root");
}
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -1,37 +0,0 @@
package fr.koka99cab.sanctuary26.sanctuary.gameplay;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.server.Bootstrap;
import net.minecraft.SharedConstants;
import net.minecraft.core.Holder;
import net.minecraft.core.component.DataComponentMap;
/** Executable coverage for the bounded adult-chicken armor equip policy. */
public final class SanctuaryChickenArmorSmoke {
private SanctuaryChickenArmorSmoke() {
}
public static void main(String[] args) {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
ItemStack armor = stack(Items.WOLF_ARMOR);
require(SanctuaryChickenArmor.canEquip(ItemStack.EMPTY, armor, false),
"Adult chickens must accept unused armadillo-scute armor");
require(!SanctuaryChickenArmor.canEquip(ItemStack.EMPTY, armor, true),
"Baby chickens must not accept body armor");
require(!SanctuaryChickenArmor.canEquip(armor.copy(), armor, false),
"Equipping must not replace an existing chicken armor stack");
require(!SanctuaryChickenArmor.canEquip(ItemStack.EMPTY,
stack(Items.IRON_CHESTPLATE), false),
"Chicken armor must stay limited to the armadillo-scute item");
}
private static ItemStack stack(net.minecraft.world.item.Item item) {
return new ItemStack(new Holder.Direct<>(item, DataComponentMap.EMPTY), 1);
}
private static void require(boolean condition, String message) {
if (!condition) throw new IllegalStateException(message);
}
}

Some files were not shown because too many files have changed in this diff Show More