Compare commits

...
Author SHA1 Message Date
koka 0ad9763666 Add standalone ZEVENT Twitch screen mod
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-04 01:53:19 +02:00
koka 0196b6efa5 Merge pull request 'release: Sanctuary 26.2.0-alpha.233' (#155) from release/26.2.0-alpha.233 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #155
2026-09-03 02:25:18 +00:00
koka 3bdc5fe241 release: prepare Sanctuary 26.2.0-alpha.233
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-03 04:10:03 +02:00
koka 789faeb708 Merge pull request 'feat(redstoner): refaire les trophées comme panneaux Vanilla' (#154) from integration/trophy-signs-vanilla into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #154
2026-09-03 02:01:31 +00:00
koka 3491158785 feat(redstoner): rebuild trophy panels as vanilla signs
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-03 03:15:02 +02:00
koka c0229d4ea9 Merge pull request 'Canon : enregistrer les photos dans les screenshots et publier alpha.232' (#153) from release/26.2.0-alpha.232 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #153
2026-09-03 00:26:13 +00:00
koka ddaf71c7cd release: prepare Sanctuary 26.2.0-alpha.232
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-03 02:22:46 +02:00
koka 7e1ed85307 Merge pull request 'Corriger la cape, le terminal et le panneau de trophées d’alpha.231' (#152) from fix/alpha231-cape-terminal-trophy-panel into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #152
2026-09-02 23:37:50 +00:00
koka 5840621acf fix: correct alpha231 cape terminal and trophy panel
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-03 01:35:09 +02:00
koka 974cea20b5 Merge pull request 'release: Sanctuary 26.2.0-alpha.231' (#151) from release/26.2.0-alpha.231 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #151
2026-09-02 22:44:12 +00:00
koka 81a98ed197 release: include readable trophy panels in alpha.231
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-02 18:42:33 +02:00
koka b022be5739 Merge pull request 'fix(redstoner): rendre les panneaux de trophées lisibles' (#150) from fix/trophy-panel-display into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #150
2026-09-02 16:29:25 +00:00
koka 27b29c1340 Merge remote-tracking branch 'origin/main' into fix/trophy-panel-display
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	CHANGELOG.md
#	build.gradle
2026-09-02 17:35:58 +02:00
koka 274d4c1ffa release: prepare Sanctuary 26.2.0-alpha.231 2026-09-02 15:16:01 +02:00
koka f217bb3661 Merge pull request 'feat(sanctuary): rendre les coffres et lits Backroom trouvables' (#149) from feature/backroom-visible-loot into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #149
2026-09-02 12:52:54 +00:00
koka 5100091b17 fix(sanctuary): rendre les lits Backroom trouvables
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-02 14:36:04 +02:00
koka 398d94e7d1 feat(sanctuary): densifier le butin des Backrooms 2026-09-02 14:26:51 +02:00
koka 0743ff33a6 fix(redstoner): rendre les panneaux de trophées lisibles
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-02 14:18:20 +02:00
koka bfda90f4f4 Merge pull request 'release: prepare Sanctuary 26.2.0-alpha.230' (#148) from release/26.2.0-alpha.230 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #148
2026-09-02 07:18:29 +00:00
koka a698140963 release: prepare Sanctuary 26.2.0-alpha.230
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-02 09:14:55 +02:00
koka 88ec91e9f4 Merge pull request 'feat(sanctuary): corriger les capes et armer les poules' (#147) from feature/final-sanctuary-tickets into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #147
2026-09-02 06:57:22 +00:00
koka e1d48e9017 feat(sanctuary): resolve final cape and chicken tickets
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-02 08:52:53 +02:00
koka cce58c27c8 Merge pull request 'feat(ranking): ajouter les panneaux de trophées automatiques' (#144) from feature/automatic-trophy-panel into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #144
2026-09-02 06:28:41 +00:00
koka 1a9263d273 Merge remote-tracking branch 'origin/main' into feature/automatic-trophy-panel
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	build.gradle
2026-09-01 21:26:08 +02:00
koka 4a74b6dfed feat(ranking): add automatic trophy panels 2026-09-01 21:24:27 +02:00
koka 06da8fbd2b Merge pull request 'feat(backroom): générer des lits de sortie sur chaque niveau' (#143) from feature/backroom-beds-every-floor into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #143
2026-09-01 19:18:22 +00:00
koka bd747afbe0 Merge origin/main into feature/backroom-beds-every-floor
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-01 21:01:18 +02:00
koka 7e459e3615 Merge pull request 'feat(sanctuary): afficher le joueur ayant complété une quête' (#142) from feature/quest-board-completed-by into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #142
2026-09-01 18:57:19 +00:00
koka 67220fa0c7 feat(backroom): spawn exit beds on every level
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-01 20:43:57 +02:00
koka a345db5aeb Merge remote-tracking branch 'origin/main' into feature/quest-board-completed-by
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	build.gradle
2026-09-01 18:59:37 +02:00
koka 0bd60d01cc feat(sanctuary): attribuer les quêtes terminées 2026-09-01 18:56:29 +02:00
koka 94e6cf380d Merge pull request 'release(packwiz): publier Sanctuary 26.2.0-alpha.229' (#141) from release/26.2.0-alpha.229 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #141
2026-09-01 16:56:02 +00:00
koka 43962f4857 release: prepare Sanctuary 26.2.0-alpha.229
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-01 18:53:37 +02:00
koka 5af573deb4 Merge pull request 'feat(redstoner): ajouter les tris du terminal' (#139) from feature/storage-terminal-sorting into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #139
2026-09-01 16:47:18 +00:00
koka e552bd68fd Merge remote-tracking branch 'origin/main' into integration/storage-terminal-sorting-alpha228
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	build.gradle
2026-09-01 18:41:49 +02:00
koka 69dd9f919d Merge pull request 'release: publier Sanctuary 26.2.0-alpha.228' (#140) from release/26.2.0-alpha.228 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #140
2026-09-01 16:36:46 +00:00
koka 2d212ae6a1 release: prepare Sanctuary 26.2.0-alpha.228
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-01 18:33:28 +02:00
koka 2b614ce5a8 Merge remote-tracking branch 'origin/main' into feature/storage-terminal-sorting
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	CHANGELOG.md
#	build.gradle
2026-09-01 18:31:32 +02:00
koka 99ac5fc3a7 Add terminal sorting modes 2026-09-01 18:30:53 +02:00
koka ab82c3929e Merge pull request 'feat: reconstruire la Backroom sur cinq niveaux' (#137) from feature/backroom-five-level-generation into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #137
2026-09-01 16:23:25 +00:00
koka 4820c736f4 Merge remote-tracking branch 'origin/main' into feature/backroom-five-level-generation
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	CHANGELOG.md
#	build.gradle
2026-09-01 18:16:37 +02:00
koka a16347140a Merge pull request 'feat(redstoner): rechercher dans les boîtes de Shulker' (#138) from feature/storage-terminal-shulker-search into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #138
2026-09-01 16:14:20 +00:00
koka 75fa88cfc5 Add Shulker contents to terminal search
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-01 18:11:05 +02:00
koka f04aeb02b2 Merge remote-tracking branch 'origin/main' into feature/backroom-five-level-generation
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	CHANGELOG.md
#	build.gradle
2026-09-01 18:09:41 +02:00
koka 4ca42cb18a Merge pull request 'fix(redstoner): rendre la recherche du terminal cliquable' (#135) from fix/storage-terminal-search-focus into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #135
2026-09-01 15:57:57 +00:00
koka 985e8b9192 feat(backroom): stack five dark generation levels
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-01 17:55:40 +02:00
koka 1d8ab0b30a Merge remote-tracking branch 'origin/main' into fix/storage-terminal-search-focus
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	build.gradle
2026-09-01 17:19:44 +02:00
koka 083c3449d5 Merge pull request 'release: publier Sanctuary 26.2.0-alpha.227' (#134) from release/26.2.0-alpha.227 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #134
2026-09-01 15:10:06 +00:00
koka 51d630037d fix(redstoner): make terminal search focus clickable
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-01 17:04:01 +02:00
koka 341f1eb8e5 release: prepare Sanctuary 26.2.0-alpha.227
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-01 17:00:02 +02:00
koka 10b606d01d Merge pull request 'feat: ajouter la dimension persistante Backroom' (#133) from feature/backroom into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #133
2026-09-01 14:50:33 +00:00
koka b39c271c96 feat(backroom): add persistent lost-item dimension
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-01 14:30:42 +02:00
koka a16156b005 Merge pull request 'feat: ajouter une cape craftable décorée par bannière' (#126) from feature/craftable-banner-cape into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #126
2026-08-31 11:44:49 +00:00
koka 2518c20071 feat(sanctuary): add craftable banner cape
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 13:25:27 +02:00
koka b7497c81d1 Merge pull request 'release: publier Sanctuary 26.2.0-alpha.225' (#125) from release/26.2.0-alpha.225 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #125
2026-08-31 09:50:25 +00:00
koka 97638e0fa2 release: prepare Sanctuary 26.2.0-alpha.225
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 11:46:39 +02:00
koka a071a0b746 fix(magic-carpet): restore the Notch Totem texture 2026-08-31 11:46:24 +02:00
koka 6fcc56552c fix(canaplia): preserve crop variants through growth 2026-08-31 11:46:17 +02:00
koka 66944ec492 fix(companions): suppress loot and quest progress 2026-08-31 11:46:02 +02:00
koka b513ab1262 fix(anotherworld): require a vault before raids 2026-08-31 11:45:46 +02:00
koka b2222c5d33 Merge pull request 'release: publier Sanctuary 26.2.0-alpha.224' (#122) from release/26.2.0-alpha.224 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #122
2026-08-31 05:17:01 +00:00
koka 2d0be21c5b release: prepare Sanctuary 26.2.0-alpha.224
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 07:13:29 +02:00
koka a0b0788869 Merge pull request 'feat: livrer et migrer le Magic Carpet après Notch' (#121) from integration/magic-carpet-reward into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #121
2026-08-31 04:59:18 +00:00
koka 106d730dd8 feat: deliver and migrate the Magic Carpet
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 06:45:28 +02:00
koka 21dd089486 Merge pull request 'feat: ajouter Notch et son combat dans Alpha' (#119) from integration/notch-encounter into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #119
2026-08-31 04:15:54 +00:00
koka e3b6c6ed01 Merge pull request 'fix(sanctuary): trier coffres et barrels et garder la hotbar en bas' (#120) from fix/issue-118-container-sort into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #120
2026-08-31 04:15:42 +00:00
ChrisM-PekandCursor 7387096d98 chore(sanctuary): aligner la verif alpha.223 sur le tri des conteneurs
Verification 26.2 / check (pull_request) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 05:54:43 +02:00
ChrisM-PekandCursor 457f91b92e fix(sanctuary): trier coffres et barrels et garder la hotbar en bas
Le clic molette range aussi les conteneurs de stockage, et la hotbar reste sur la derniere ligne a la connexion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 05:52:17 +02:00
239 changed files with 11844 additions and 572 deletions
+115 -4
View File
@@ -6,16 +6,127 @@ 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 distribué
## Non publié
Modules modifiés : **It's Alive `0.0.0-alpha.37`**, **Ouch `0.0.0-alpha.11`**, **Sanctuary `0.0.0-alpha.126`** et **MasterKey `0.0.0-alpha.9`**. Le pack public reste temporairement en `26.2.0-alpha.223` jusqu'à la publication.
Aucun changement non publié.
## `26.2.0-alpha.233`
Modules modifiés : **Sanctuary `0.0.0-alpha.139`** et **Red-Stoner `0.0.0-alpha.20`**.
- remplace le bloc-écran `redstoner:trophy_panel` par un vrai panneau Vanilla en chêne pâle, posable au sol ou au mur, sans changer lID de litem déjà présent dans les inventaires et recettes ;
- ouvre à la première pose un éditeur blanc permettant de rechercher une statistique Vanilla, composer et réordonner jusqu’à seize pages, puis choisir entre noms et valeurs, noms seuls ou valeurs seules ;
- verrouille la configuration et la destruction au créateur ou à un opérateur, tout en laissant le clic droit public faire défiler les pages ;
- conserve lancien bloc uniquement comme alias de migration : chaque plaque déjà posée devient automatiquement un panneau mural en gardant propriétaire, statistiques et page courante ;
- répare les classements importés qui montraient les huit premiers caractères de lUUID : Sanctuary récupère désormais le vrai pseudo depuis le cache local du serveur et utilise `?` plutôt quun fragment dUUID si aucun nom nest connu ;
- ajoute les payloads bornés de l’éditeur, fait passer Red-Stoner aux données v5 et au protocole réseau 9, sans changer les données v15 ni le protocole 24 de Sanctuary. Client et serveur Red-Stoner doivent être mis à jour ensemble.
## `26.2.0-alpha.232`
Modules modifiés : **Another World `0.0.0-alpha.58`**, **Sanctuary `0.0.0-alpha.138`** et **Red-Stoner `0.0.0-alpha.19`**.
- ferme le ticket #146 en enregistrant chaque cliché pris avec un appareil Canon dans le dossier `screenshots` de linstance, à côté des captures F2 ;
- exporte le carré cadré à sa résolution native avec le filtre de la pellicule, sans le réduire aux 128 × 128 pixels de la carte en jeu ;
- écrit le PNG hors du thread de rendu et réserve un nom horodaté unique afin d’éviter les freezes comme les écrasements lors de plusieurs clichés rapprochés ;
- remplace le volume de la cape à motifs par les deux faces opposées de l’îlot UV exact du bouclier, redimensionnées aux proportions 10 × 16 de la cape, afin de corriger la projection et le culling sans afficher le manche ni l’épaisseur du bouclier ;
- laisse la touche dinventaire fermer le terminal tant que la recherche nest pas focalisée, mais la traite comme du texte dans le champ de recherche afin que la lettre `E` ne ferme plus l’écran ;
- utilise les huit lignes du panneau de trophées pour afficher le titre et jusqu’à sept rangs, avec une page de noms suivie dune page de valeurs pour chaque statistique afin que les longues durées de jeu restent lisibles ;
- fait configurer par un œuf dapparition le classement vanilla des créatures correspondantes tuées, par exemple les zombies tués avec l’œuf de zombie ;
- conserve les IDs et les sauvegardes Sanctuary v15/Red-Stoner v4 ; le snapshot client du panneau passant de trois à sept entrées, le protocole Red-Stoner passe de 7 à 8 et exige la mise à jour du client et du serveur.
## `26.2.0-alpha.231`
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 dun 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 dun 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 quils soient réellement trouvables ;
- actualise JEI vers la bêta Fabric `30.29.0.201`, qui corrige un crash avec les mods utilisant latlas de textures ;
- ne change ni le butin ordinaire des coffres, ni les données Sanctuary v15/Red-Stoner v4, ni leurs protocoles réseau 24/7 ; les nouveaux coffres et lits apparaissent dans les chunks qui nont 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 lAPI 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 larmure de loup vanilla en écailles de tatou, de la réparer avec des écailles et de la retirer aux cisailles ; larmure 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 : larmure 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 à louverture puis focalisable au clic, libère le focus au clic extérieur et laisse toujours la touche dinventaire fermer le terminal ;
- permet à la recherche du terminal de retrouver une boîte de Shulker par le nom ou lidentifiant dun objet quelle 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 saccroupissant 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 dentré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 dAnother World aux anomalies et les blocs précieux à de rares cachettes ;
- fait enregistrer la mousse turquoise par Another World via lAPI 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.
- ajoute `sanctuary:white_cape`, une cape blanche craftable avec six laines blanches disposées comme une porte, en deux colonnes de trois ;
- permet d'associer simplement la cape et n'importe quelle bannière dans l'établi afin d'en conserver la couleur de fond et jusqu'à six motifs ;
- affiche les motifs dans l'inventaire et sur la cape équipée, avec la physique native des capes, y compris en multijoueur ;
- conserve les sauvegardes Sanctuary en data v13 ; le payload d'apparence transporte désormais la couleur et les motifs bornés, donc le protocole Sanctuary passe de 23 à 24 et client comme serveur doivent être mis à jour, sans nouveau monde ni réinitialisation.
## `26.2.0-alpha.225`
Modules modifiés : **Another World `0.0.0-alpha.56`**, **It's Alive `0.0.0-alpha.38`**, **I Like To Move It `0.0.0-alpha.18`**, **Only Fun `0.0.0-alpha.27`** et **Sanctuary `0.0.0-alpha.129`**. Les cinq autres modules conservent leur version issue de l'alpha.224.
- exige que lattaquant possède son propre coffre-fort actif avant de commencer un braquage, revalide cette condition pendant le forage et refuse défensivement tout transfert si le coffre disparaît entre-temps ;
- empêche les familiers temporaires Sanctuary de produire du butin vanilla, de lexpérience ou une lootbox Only Fun, et les exclut des contrats de panneau comme des demandes communautaires ;
- conserve la variante Canaplia des seize graines historiques pendant le placement, la croissance naturelle, la poudre dos, la création de la partie haute, le rechargement et la récolte, ce qui réactive notamment lhybridation Tendre + Olive vers Dorée ;
- remplace la laine violette du Magic Carpet par la texture historique 16 × 16 du Totem de Notch, utilisée par lobjet canonique et son alias hérité sans modifier la plateforme 3 × 3 ;
- conserve tous les IDs, sauvegardes et formats de données ; les protocoles réseau restent Another World 2, It's Alive 4, I Like To Move It 5, Only Fun 3 et Sanctuary 23, sans nouveau monde ni réinitialisation.
## `26.2.0-alpha.224`
Modules modifiés : **It's Alive `0.0.0-alpha.37`**, **Ouch `0.0.0-alpha.11`**, **I Like To Move It `0.0.0-alpha.17`**, **Only Fun `0.0.0-alpha.26`**, **Sanctuary `0.0.0-alpha.128`** et **MasterKey `0.0.0-alpha.9`**.
- étend le clic molette aux coffres, barils, boîtes de Shulker, entonnoirs et distributeurs, tout en excluant les fours, le Shop et le terminal Red-Stoner ;
- trie l'inventaire du joueur lorsque le curseur survole l'une de ses cases dans un conteneur, sans toucher à la hotbar, et replace la hotbar en bas après connexion ou achat d'une rangée ;
- ajoute Notch comme résident unique et persistant de la maison Indev, avec restauration automatique, déduplication et lecture des anciennes entités `portals:notch` ;
- transforme Notch en Titan au premier coup volontaire, avec santé bornée selon les participants, poursuite, attaques télégraphiées, boules de feu renvoyables et réinitialisation si le combat est abandonné ;
- limite la destruction de blocs à l'explosion initiale de transformation ; les attaques suivantes utilisent uniquement dégâts, poussée, particules et explosions non destructrices ;
- enregistre la première victoire comme jalon mondial monotone, crédite les participants, inscrit un marqueur de boss dans leur Atlas déjà découvert et réserve un drapeau de récompense unique pour la suite ;
- enregistre la première victoire comme jalon mondial monotone, crédite les participants, inscrit un marqueur de boss dans leur Atlas déjà découvert et réserve une récompense physique unique ;
- place le Magic Carpet de Notch dans un coffre déterministe signalé par feu d'artifice, avec réservation et livraison persistées séparément pour récupérer une interruption sans duplication ;
- conserve `portals:notch_totem` comme alias caché et convertit inventaires, conteneurs et items au sol vers `iliketomoveit:magic_carpet` en gardant quantité, composants et nom personnalisé ;
- maintient la plateforme temporaire 3 × 3 sous autorité serveur, sans écraser le terrain et avec un suivi par propriétaire qui protège les tapis superposés pendant les nettoyages ;
- rend l'offre Magic Carpet visible et achetable uniquement après `sanctuary:notch_defeated`, au prix exceptionnel existant, avec paiement normal, livraison Mailbox, provenance et journal de remplacement ;
- ajoute les diagnostics `/masterkey notch inspect` et `/masterkey notch restore`, les traductions FR/EN/RU et les contrats de test déterministes associés ;
- migre les données It's Alive de v3 à v4 et Sanctuary de v11 à v12, sans changer les protocoles réseau ni le pack public.
- migre les données It's Alive de v3 à v4, I Like To Move It de v3 à v4 et Sanctuary de v11 à v13 ; le payload de tri ayant gagné un booléen borné, le protocole Sanctuary passe de 22 à 23 et client comme serveur doivent être mis à jour, sans nouveau monde ni réinitialisation.
## `26.2.0-alpha.223`
+30 -9
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.158.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25.
Workspace Fabric multi-projet de Sanctuary 2.0. Il construit les dix JAR du pack et le mod événementiel ZEVENT distribué à part, 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.
Auteur : **KOKA99CAB**
Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
@@ -12,33 +12,50 @@ 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, Drawer et Particuleur |
| `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, terminal de stockage, panneau de trophées, 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, Atlas et monde principal flottant |
| `sanctuary` | Sanctuary | progression et aventure | progression globale, classements publics, Atlas et monde principal flottant |
| `events` | Events | événements serveur | socle vide |
| `masterkey` | MasterKey | administration et diagnostic | laboratoire minimal |
| `zevent` | ZEVENT | diffusion Twitch temporaire | écran mural 9×5, hors pack principal |
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.223`**. Cette release distribue Sanctuary **`0.0.0-alpha.125`** ; les neuf autres modules conservent leur version issue de l'alpha.222.
Version courante du pack distribué : **`26.2.0-alpha.233`**. Cette release distribue Another World **`0.0.0-alpha.58`**, Sanctuary **`0.0.0-alpha.139`** et Red-Stoner **`0.0.0-alpha.20`** ; les sept autres modules conservent leur version issue de l'alpha.231.
Lalpha.233 remplace le bloc-écran des trophées par un véritable panneau Vanilla en chêne pâle, posable au sol ou au mur. Son éditeur permet de rechercher une statistique, composer jusqu’à seize pages et choisir laffichage des noms et valeurs ; le propriétaire ou un opérateur garde le contrôle de sa configuration tandis que le changement de page reste public. Litem `redstoner:trophy_panel` conserve son ID et les anciens panneaux posés sont migrés automatiquement. Les classements affichent désormais le vrai pseudo connu du joueur au lieu dun fragment dUUID. Les sauvegardes Sanctuary restent en données v15/protocole 24 ; Red-Stoner passe aux données v5 et au protocole 9, ce qui exige la même version côté client et serveur.
Lalpha.232 enregistre automatiquement chaque cliché Canon dans le dossier `screenshots` de linstance, à côté des captures F2. Le PNG conserve le carré réellement cadré, sa résolution et le filtre de la pellicule ; l’écriture asynchrone et les noms uniques évitent les freezes et les écrasements. Cette release corrige aussi la cape blanche décorée, la touche `E` du terminal et étend le panneau de trophées à huit lignes. Chaque statistique y possède une page de noms puis une page de valeurs ; un œuf dapparition ajoute directement le classement des créatures correspondantes tuées. Les sauvegardes restent en données Another World v5, Sanctuary v15 et Red-Stoner v4 ; le snapshot du panneau passant à sept entrées, le protocole Red-Stoner passe à 8.
Lalpha.231 rend les sorties et le butin de la Backroom réellement trouvables sur ses cinq niveaux. Chaque niveau garantit désormais un coffre et un lit de sortie par cellule de 3 × 3 chunks dans les nouveaux chunks, sans ajouter de livres au butin. Le panneau de trophées devient une plaque verticale de chêne avec quatre lignes lisibles : titre et numéro de page, puis les trois places du podium. Un clic droit avance dune page et `Maj` + clic droit revient à la précédente. JEI passe à la bêta Fabric `30.29.0.201`, qui corrige un crash lié à latlas de textures. Les sauvegardes et les chunks déjà générés restent compatibles ; Sanctuary conserve les données v15/protocole 24 et Red-Stoner les données v4/protocole 7.
Lalpha.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 larmure 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.
Lalpha.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 lordre ainsi que les objets sous le curseur. Les sauvegardes, les données Red-Stoner v3 et le protocole réseau 7 restent compatibles.
Lalpha.228 reconstruit la Backroom sur cinq niveaux empilés entre Y -64 et Y 383. Le monde devient entièrement noir et sans monstres ; larrivé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.
Lalpha.227 ajoute la dimension persistante `sanctuary:backroom`. On y entre accroupi devant un lit et on nen ressort que par un lit. L’équipement dentré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.
Lalpha.225 corrige quatre tickets : posséder un coffre-fort devient obligatoire pour en braquer un autre, les familiers temporaires ne donnent plus de butin, dexpé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.
La migration [CANA-01 à CANA-09](CANAPLIA.md) porte Canaplia vers Minecraft 26.2. It's Alive **`0.0.0-alpha.35`** enregistre les seize familles, les découvertes, hybridations, résines, effets serveur et le Livre interdit personnel, partageable temporairement sur un pupitre. Ambiance **`0.0.0-alpha.14`** compose les profils visuels bornés avec ses réglages d'accessibilité, et Only Fun **`0.0.0-alpha.21`** exclut explicitement Canaplia et les raccourcis de progression du Shop.
Lalpha.224 ajoute Notch comme résident unique de la maison Indev et son combat de Titan. It's Alive possède l'entité persistante, Ouch les attaques et dégâts, Sanctuary le jalon mondial de victoire et l'Atlas, tandis que MasterKey fournit les commandes d'inspection et de restauration. Les anciennes entités `portals:notch` restent chargeables. La première victoire réserve maintenant un unique `iliketomoveit:magic_carpet` dans un coffre signalé par feu d'artifice. L'ancien `portals:notch_totem` est migré sans perte ; après Notch, Only Fun ouvre l'offre de remplacement au Shop avec paiement serveur et livraison par Mailbox. Le clic molette trie aussi les coffres, barils, boîtes de Shulker, entonnoirs et distributeurs, ou l'inventaire du joueur lorsqu'il est survolé, sans déplacer la hotbar. Le protocole Sanctuary passe à 23 : client et serveur doivent utiliser l'alpha.224.
Lalpha.223 ajoute le tri natif des rangées de stockage par clic molette sans déplacer la hotbar ni les emplacements spéciaux. Le suivi des quêtes propose désormais le texte transparent aligné sous la colonne gauche du F3 ou le panneau original de Chris, avec bascule configurable sur `N` et contrats ordonnés par récompense. JEI passe à la bêta Fabric 26.2 `30.29.0.198`.
Lalpha.222 améliore le terminal de stockage avec une recherche Unicode, des quantités compactes et des emplacements qui ne glissent plus lorsqu'une pile est vidée. Elle ajoute aussi le suivi textuel des quêtes actives en haut à gauche, masquable avec une touche configurable, ainsi que des suggestions dIDs ditems et de mobs dans l’éditeur de demandes. JEI passe à la bêta Fabric 26.2 `30.29.0.196`.
Le lot non distribué ajoute Notch comme résident unique de la maison Indev et son combat de Titan. It's Alive possède l'entité persistante, Ouch les attaques et dégâts, Sanctuary le jalon mondial de victoire et l'Atlas, tandis que MasterKey fournit les commandes d'inspection et de restauration. Les anciennes entités `portals:notch` restent chargeables.
Lalpha.221 corrige la cause réelle du disque blanc ralenti et trop grave : Ambiance décode désormais la source AAC en stéréo native à 48 kHz, puis moyenne explicitement chaque paire gauche/droite vers le flux mono positionnel de Minecraft. OpenAL reste à sa hauteur normale `1.0`, sans compensation artificielle ni changement de durée.
Lalpha.220 transforme le terminal de stockage en véritable menu de conteneur, sans bouton de fermeture propriétaire. Les clics gauche/droit et shift-clic permettent maintenant de retirer ou déposer des piles depuis toutes les rangées dinventaire débloquées par Sanctuary, jusqu’à six ; la grille agrégée reste non réorganisable et remplit chaque coffre ou baril dans lordre de découverte avant de passer au suivant.
Lalpha.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).
Lalpha.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 ditems restent strictement séparés et le terminal endgame est exclu du Shop, du Black Market et des récompenses Only Fun.
Lalpha.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 ditems 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 daprè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.
Lalpha.217 tentait de compenser la hauteur trop grave du disque blanc par un facteur OpenAL de `4/3`. Cette compensation est retirée par lalpha.221, qui corrige directement la conversion stéréo vers mono.
@@ -114,6 +131,8 @@ Lalpha.173 centralise laudio du Miner : une seule boucle de frappe sur le
Lalpha.171 rassemble les **46 capes** dans un onglet créatif dédié « Capes ». Elles ne sont plus mélangées à longlet vanilla Tools & Utilities et ne sont donc affichées quune seule fois.
Lalpha.226 ajoute une 47e cape, `sanctuary:white_cape`, distincte de la collection Only Fun. Elle se fabrique avec six laines blanches en deux colonnes de trois, puis accepte une bannière dans une recette simple pour en conserver la couleur et jusqu’à six motifs ; les motifs saffichent dans linventaire comme une fois la cape équipée.
Lalpha.170 ajoute les pictogrammes fournis aux emplacements vides de la cape et du familier. Ils disparaissent dès quune cape ou un œuf est équipé, sans modifier les objets, la persistance ni le protocole.
Lalpha.169 agrandit la collection de 13 à **46 capes équipables**. Les 33 nouvelles textures 64×32 fournies sont conservées telles quelles, avec une icône dinventaire, un modèle et des traductions FR/EN/RU pour chaque cape. Elles utilisent le slot, la persistance et le rendu multijoueur déjà existants, restent sans recette et rejoignent toutes la lootbox de capes Only Fun. Les caméras Canon restent accessibles dans la lootbox normale et peuvent désormais aussi apparaître directement, très rarement, dans les coffres dexploration et du donjon de cobblestone.
@@ -309,7 +328,9 @@ Le guide détaillé est dans [`masterkey/tools/PANORAMA.md`](masterkey/tools/PAN
## Red-Stoner et photographie
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.
Red-Stoner possède ses appareils sous son namespace ainsi que la paire de panneaux Vanilla `trophy_sign`/`trophy_wall_sign`. Litem historique `redstoner:trophy_panel` est conservé afin que les inventaires et recettes existants restent valides ; il place désormais cette paire, tandis que lancien bloc mural du même ID ne subsiste que le temps de sa migration automatique. Son onglet créatif expose le panneau de trophées avec les ordinateurs, le Particuleur et le terminal de stockage.
Le panneau de trophées consomme lAPI publique de classement de Sanctuary sans conserver les scores. Sa première pose ouvre un éditeur blanc : le créateur choisit dans le catalogue Vanilla jusqu’à seize statistiques, leur ordre et un affichage noms+valeurs, noms seuls ou valeurs seules. `Maj` + clic droit rouvre ensuite cet éditeur pour le créateur ou un opérateur ; le clic droit ordinaire fait défiler publiquement les pages. La configuration et la destruction sont protégées par UUID. Chaque page utilise les quatre lignes natives du panneau — titre puis trois rangs — et se rafraîchit côté serveur. Sanctuary importe une fois les statistiques Vanilla existantes, conserve les classements par UUID — champion déconnecté compris — et résout les vrais pseudos depuis le cache du serveur au lieu dafficher un début dUUID.
L'ordinateur 8 bits et le contrôleur exécutent la machine virtuelle historique : programme assembleur borné à 4096 caractères, RAM, inventaire, six ports redstone directionnels, impulsions et sortie comparateur. Le contrôleur dispose de 1024 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é.
@@ -398,7 +419,7 @@ Le menu pause est lui aussi possédé par Sanctuary. Il ne conserve que Retour a
## Versions du modpack et mises à jour Modrinth
`pack_version` dans `gradle.properties` est la version du pack complet. `mod_version=0.0.0-alpha.0` et `module_lifecycle=standby` sont les valeurs par défaut des modules en veille ; les propriétés `<module>_version` et `<module>_lifecycle` nactivent et ne font avancer que le module concerné. Le contrat de distribution est dans `pack/release.json`.
`pack_version` dans `gradle.properties` est la version du pack complet. `mod_version=0.0.0-alpha.0` et `module_lifecycle=standby` sont les valeurs par défaut des modules en veille ; les propriétés `<module>_version` et `<module>_lifecycle` nactivent et ne font avancer que le module concerné. Le contrat de distribution est dans `pack/release.json`. Le sous-projet `zevent` reste volontairement hors de `moduleIds` et des archives du pack : son JAR et sa dépendance cliente MCEF sont distribués séparément pour l’événement.
La version globale du pack nest pas injectée dans les JAR inchangés : leur contenu et leur numéro restent immuables. Le lanceur possède la composition exacte du pack, tandis que chaque manifest conserve la version effective de son module.
+44 -2
View File
@@ -15,6 +15,14 @@ tasks.register("vaultRulesSmoke", JavaExec) {
mainClass = "fr.koka99cab.sanctuary26.anotherworld.vault.VaultRulesSmoke"
}
tasks.register("canonScreenshotFilesSmoke", JavaExec) {
group = "verification"
description = "Checks Canon photo exports use the vanilla screenshots directory and collision-safe names."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.canon.client.CanonScreenshotFilesSmoke"
}
tasks.named("test") {
failOnNoDiscoveredTests = false
}
@@ -1647,6 +1655,7 @@ tasks.register("generateAnotherWorldResources") {
"message.anotherworld.safe_vault.empty_before_break": "Videz le coffre-fort et attendez la fin du forage avant de le démonter.",
"message.anotherworld.safe_vault.drill_own": "Vous ne pouvez pas forer votre propre coffre-fort.",
"message.anotherworld.safe_vault.inactive": "Ce coffre-fort nest pas actif.",
"message.anotherworld.safe_vault.raid_requires_vault": "Vous devez posséder un coffre-fort actif avant den braquer un autre.",
"message.anotherworld.safe_vault.empty": "Ce coffre-fort est vide.",
"message.anotherworld.safe_vault.owner_offline": "Le propriétaire doit être connecté pour que le coffre-fort soit attaquable.",
"message.anotherworld.safe_vault.protected": "Ce coffre-fort reste protégé pendant environ %s minute(s).",
@@ -1671,6 +1680,7 @@ tasks.register("generateAnotherWorldResources") {
"message.anotherworld.safe_vault.empty_before_break": "Опустошите сейф и дождитесь окончания сверления перед разборкой.",
"message.anotherworld.safe_vault.drill_own": "Нельзя сверлить собственный сейф.",
"message.anotherworld.safe_vault.inactive": "Этот сейф не активен.",
"message.anotherworld.safe_vault.raid_requires_vault": "Для ограбления чужого сейфа нужен собственный активный сейф.",
"message.anotherworld.safe_vault.empty": "Этот сейф пуст.",
"message.anotherworld.safe_vault.owner_offline": "Владелец должен быть в сети, чтобы атаковать сейф.",
"message.anotherworld.safe_vault.protected": "Сейф защищён ещё примерно %s минут(ы).",
@@ -1695,6 +1705,7 @@ tasks.register("generateAnotherWorldResources") {
"message.anotherworld.safe_vault.empty_before_break": "Empty the safe and wait for drilling to end before dismantling it.",
"message.anotherworld.safe_vault.drill_own": "You cannot drill your own safe.",
"message.anotherworld.safe_vault.inactive": "This safe is not active.",
"message.anotherworld.safe_vault.raid_requires_vault": "You need an active safe of your own before raiding another one.",
"message.anotherworld.safe_vault.empty": "This safe is empty.",
"message.anotherworld.safe_vault.owner_offline": "The owner must be online before the safe can be raided.",
"message.anotherworld.safe_vault.protected": "This safe remains protected for about %s minute(s).",
@@ -1953,7 +1964,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.55"
if (project.version.toString() != "0.0.0-alpha.58"
|| sulfurMigration.target?.modules?.anotherworld != "0.0.0-alpha.44"
|| sulfurMigration.sulfur_caves?.sanctuary?.dripstone_cell_period != 2
|| !javaText.contains('Identifier.parse("sanctuary:sulfur_caves")')
@@ -3258,6 +3269,7 @@ tasks.register("verifyAnotherWorldCatalog") {
tasks.named("check") {
dependsOn tasks.named("verifyAnotherWorldCatalog")
dependsOn tasks.named("vaultRulesSmoke")
dependsOn tasks.named("canonScreenshotFilesSmoke")
}
tasks.register("verifyClimateBiomeMixer") {
@@ -3270,7 +3282,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.55"
if (project.version.toString() != "0.0.0-alpha.58"
|| 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"
@@ -3297,3 +3309,33 @@ 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.58"
|| !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")
}
@@ -0,0 +1,38 @@
package fr.koka99cab.canon.client;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
final class CanonScreenshotFiles {
private static final String SCREENSHOT_DIRECTORY = "screenshots";
private static final int MAX_SUFFIX = 10_000;
private CanonScreenshotFiles() {
}
static Path reserve(Path gameDirectory, String timestamp) throws IOException {
Path screenshots = gameDirectory.resolve(SCREENSHOT_DIRECTORY);
Files.createDirectories(screenshots);
for (int suffix = 1; suffix <= MAX_SUFFIX; suffix++) {
Path candidate = screenshots.resolve(fileName(timestamp, suffix));
try {
return Files.createFile(candidate);
} catch (FileAlreadyExistsException ignored) {
// Keep the vanilla timestamp readable while avoiding same-second collisions.
}
}
throw new IOException("Could not reserve a unique Canon screenshot filename");
}
static String fileName(String timestamp, int suffix) {
if (timestamp == null || timestamp.isBlank()) {
throw new IllegalArgumentException("timestamp must not be blank");
}
if (suffix < 1) {
throw new IllegalArgumentException("suffix must be positive");
}
return timestamp + "-canon" + (suffix == 1 ? "" : "_" + suffix) + ".png";
}
}
@@ -8,12 +8,15 @@ import fr.koka99cab.canon.network.CapturePhotoPayload;
import fr.koka99cab.canon.photo.PhotoMapPalette;
import com.mojang.blaze3d.platform.NativeImage;
import com.mojang.blaze3d.platform.Window;
import java.nio.file.Files;
import java.nio.file.Path;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import net.minecraft.network.chat.Component;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.util.ARGB;
import net.minecraft.util.Util;
import net.minecraft.world.item.ItemStack;
public final class ClientPhotoCapture {
@@ -65,6 +68,7 @@ public final class ClientPhotoCapture {
ClientPlayNetworking.send(new CapturePhotoPayload(colors));
client.player.getCooldowns().addCooldown(capture.cameraStack(), CanonConstants.CAMERA_COOLDOWN_TICKS);
client.player.playSound(SoundEvents.SPYGLASS_USE, 0.7F, 1.35F);
exportScreenshot(client, screenshot, capture.style(), offsetX, offsetY, cropWidth, cropHeight);
} catch (Exception exception) {
CanonMod.LOGGER.warn("Failed to capture photo", exception);
if (client.player != null) {
@@ -74,6 +78,72 @@ public final class ClientPhotoCapture {
});
}
private static void exportScreenshot(
Minecraft client,
NativeImage screenshot,
PhotoStyle style,
int offsetX,
int offsetY,
int cropWidth,
int cropHeight
) {
NativeImage exported = null;
Path target = null;
try {
exported = new NativeImage(cropWidth, cropHeight, false);
screenshot.copyRect(exported, offsetX, offsetY, 0, 0, cropWidth, cropHeight, false, false);
target = CanonScreenshotFiles.reserve(client.gameDirectory.toPath(), Util.getFilenameFormattedDateTime());
} catch (Exception exception) {
if (exported != null) exported.close();
if (target != null) deleteReservedFile(target);
notifyScreenshotFailure(client, exception);
return;
}
NativeImage image = exported;
Path output = target;
Util.ioPool().execute(() -> {
try (image) {
applyStyle(image, style);
image.writeToFile(output);
client.execute(() -> {
if (client.player != null) {
client.player.sendSystemMessage(Component.translatable(
"message.canon.camera.screenshot_saved", output.getFileName().toString()));
}
});
} catch (Exception exception) {
deleteReservedFile(output);
notifyScreenshotFailure(client, exception);
}
});
}
private static void applyStyle(NativeImage image, PhotoStyle style) {
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
image.setPixel(x, y, style.applyFilter(image.getPixel(x, y)));
}
}
}
private static void notifyScreenshotFailure(Minecraft client, Exception exception) {
CanonMod.LOGGER.warn("Failed to save Canon photo in the screenshots directory", exception);
client.execute(() -> {
if (client.player != null) {
client.player.sendSystemMessage(Component.translatable("message.canon.camera.screenshot_failed"));
}
});
}
private static void deleteReservedFile(Path target) {
try {
Files.deleteIfExists(target);
} catch (Exception cleanupException) {
CanonMod.LOGGER.debug("Could not remove incomplete Canon screenshot {}", target, cleanupException);
}
}
private static int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
@@ -4,7 +4,9 @@ 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;
@@ -26,6 +28,7 @@ public final class AnotherWorldMod implements ModInitializer {
@Override
public void onInitialize() {
AnotherWorldRegistries.initialize();
BackroomMaterialApi.registerAnomalyMaterial(AnotherWorldBlocks.TURQUOISE_MOSS);
VaultService.initialize();
WeatherCompassItem.registerFrameInteraction();
CanonMod.initialize();
@@ -113,6 +113,12 @@ public final class VaultData extends SavedData {
&& address.pos() == center.asLong() && address.owner().equals(ownerId));
}
public boolean hasRegisteredVault(UUID owner) {
if (owner == null) return false;
String ownerId = owner.toString();
return vaults.stream().anyMatch(address -> address.owner().equals(ownerId));
}
public void unregisterNear(ServerLevel level, BlockPos member) {
String dimension = dimension(level);
boolean changed = vaults.removeIf(address -> {
@@ -163,7 +169,7 @@ public final class VaultData extends SavedData {
/** Transfers the exact 20% raid share into the attacker's unlimited balances without item spam. */
public long transferRaid(UUID owner, UUID attacker) {
if (owner == null || attacker == null || owner.equals(attacker)) return 0L;
if (!canReceiveRaidLoot(owner, attacker, hasRegisteredVault(attacker))) return 0L;
Account source = accounts.get(owner);
if (source == null) return 0L;
Account target = accounts.computeIfAbsent(attacker, ignored -> new Account());
@@ -180,6 +186,10 @@ public final class VaultData extends SavedData {
return total;
}
static boolean canReceiveRaidLoot(UUID owner, UUID attacker, boolean attackerHasRegisteredVault) {
return owner != null && attacker != null && !owner.equals(attacker) && attackerHasRegisteredVault;
}
public long protectionRemaining(UUID owner, long gameTime) {
Account account = accounts.get(owner);
return account == null ? 0L : Math.max(0L, account.protectedUntil - gameTime);
@@ -48,6 +48,10 @@ public final class VaultDrillingService {
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.inactive"));
return false;
}
if (!data.hasRegisteredVault(attacker.getUUID())) {
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.raid_requires_vault"));
return false;
}
if (!data.hasContents(formed.owner())) {
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.empty"));
return false;
@@ -104,6 +108,13 @@ public final class VaultDrillingService {
attacker.stopUsingItem();
return;
}
if (!VaultData.get(level.getServer()).hasRegisteredVault(attacker.getUUID())) {
cancel(attacker, false);
attacker.sendOverlayMessage(Component.translatable(
"message.anotherworld.safe_vault.raid_requires_vault"));
attacker.stopUsingItem();
return;
}
int elapsed = session.elapsedTicks() + 1;
Session progressed = session.withElapsed(elapsed);
@@ -24,6 +24,8 @@
"message.canon.camera.full": "The camera is already full.",
"message.canon.camera.captured": "Photo captured.",
"message.canon.camera.capture_failed": "Could not capture the current frame.",
"message.canon.camera.screenshot_saved": "Photo saved to screenshots: %s",
"message.canon.camera.screenshot_failed": "The in-game photo was captured, but its PNG could not be saved to screenshots.",
"style.canon.white": "Neutral",
"style.canon.white.effect": "Neutral stock with soft paper whites.",
"style.canon.light_gray": "Matte",
@@ -24,6 +24,8 @@
"message.canon.camera.full": "L'appareil photo est deja plein.",
"message.canon.camera.captured": "Photo prise.",
"message.canon.camera.capture_failed": "Impossible de capturer l'image actuelle.",
"message.canon.camera.screenshot_saved": "Photo sauvegardee dans screenshots : %s",
"message.canon.camera.screenshot_failed": "La photo en jeu est prise, mais son PNG n'a pas pu etre sauvegarde dans screenshots.",
"style.canon.white": "Neutre",
"style.canon.white.effect": "Rendu neutre avec des blancs doux.",
"style.canon.light_gray": "Mat",
@@ -24,6 +24,8 @@
"message.canon.camera.full": "Фотоаппарат уже полностью заряжен.",
"message.canon.camera.captured": "Фотография сделана.",
"message.canon.camera.capture_failed": "Не удалось снять текущий кадр.",
"message.canon.camera.screenshot_saved": "Фотография сохранена в screenshots: %s",
"message.canon.camera.screenshot_failed": "Снимок в игре сделан, но PNG не удалось сохранить в screenshots.",
"style.canon.white": "Нейтральный",
"style.canon.white.effect": "Нейтральная плёнка с мягкими белыми тонами.",
"style.canon.light_gray": "Матовый",
@@ -31,7 +31,7 @@
"java": ">=25",
"fabric-api": ">=0.154.2+26.2",
"ambiance": "*",
"sanctuary": ">=0.0.0-alpha.117"
"sanctuary": ">=0.0.0-alpha.131"
},
"custom": {
"sanctuary26": {
@@ -0,0 +1,33 @@
package fr.koka99cab.canon.client;
import java.nio.file.Files;
import java.nio.file.Path;
public final class CanonScreenshotFilesSmoke {
private CanonScreenshotFilesSmoke() {
}
public static void main(String[] args) throws Exception {
String timestamp = "2026-09-03_01.23.45";
require(CanonScreenshotFiles.fileName(timestamp, 1).equals("2026-09-03_01.23.45-canon.png"),
"first screenshot filename");
require(CanonScreenshotFiles.fileName(timestamp, 2).equals("2026-09-03_01.23.45-canon_2.png"),
"same-second screenshot filename");
Path gameDirectory = Files.createTempDirectory("canon-screenshot-smoke-");
Path first = CanonScreenshotFiles.reserve(gameDirectory, timestamp);
Path second = CanonScreenshotFiles.reserve(gameDirectory, timestamp);
require(first.getParent().equals(gameDirectory.resolve("screenshots")),
"screenshots directory");
require(first.getFileName().toString().equals("2026-09-03_01.23.45-canon.png"),
"first reserved screenshot");
require(second.getFileName().toString().equals("2026-09-03_01.23.45-canon_2.png"),
"collision-safe screenshot");
require(Files.isRegularFile(first) && Files.isRegularFile(second),
"reserved screenshot files");
}
private static void require(boolean condition, String contract) {
if (!condition) throw new IllegalStateException("Invalid Canon screenshot contract: " + contract);
}
}
@@ -1,5 +1,7 @@
package fr.koka99cab.sanctuary26.anotherworld.vault;
import java.util.UUID;
public final class VaultRulesSmoke {
private VaultRulesSmoke() {
}
@@ -55,6 +57,14 @@ public final class VaultRulesSmoke {
require(VaultRules.raidLoot(Integer.MAX_VALUE) == 429_496_730L, "32-bit balance raid");
require(VaultRules.raidLoot(Long.MAX_VALUE) == 1_844_674_407_370_955_162L,
"64-bit balance raid without overflow");
UUID owner = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID attacker = UUID.fromString("00000000-0000-0000-0000-000000000002");
require(!VaultData.canReceiveRaidLoot(owner, attacker, false),
"an attacker without a registered vault cannot receive raid loot");
require(VaultData.canReceiveRaidLoot(owner, attacker, true),
"an attacker with a registered vault can receive raid loot");
require(!VaultData.canReceiveRaidLoot(owner, owner, true),
"a vault owner cannot receive loot from their own vault");
require(VaultRules.saturatingAdd(Long.MAX_VALUE - 2L, 10L) == Long.MAX_VALUE,
"balance saturation without overflow");
for (long balance : new long[] {0L, Integer.MAX_VALUE, (long) Integer.MAX_VALUE + 1L, Long.MAX_VALUE}) {
+1826 -302
View File
File diff suppressed because it is too large Load Diff
+10 -8
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.158.0+26.2
fabric_api_version=0.159.0+26.2
# Sanctuary 26.2
# Standby modules stay at zero until their migration really begins.
@@ -14,21 +14,23 @@ 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.13
redstoner_version=0.0.0-alpha.20
redstoner_lifecycle=active
anotherworld_version=0.0.0-alpha.55
anotherworld_version=0.0.0-alpha.58
anotherworld_lifecycle=active
itsalive_version=0.0.0-alpha.37
itsalive_version=0.0.0-alpha.38
itsalive_lifecycle=active
ouch_version=0.0.0-alpha.11
ouch_lifecycle=active
masterkey_version=0.0.0-alpha.9
masterkey_lifecycle=active
iliketomoveit_version=0.0.0-alpha.16
iliketomoveit_version=0.0.0-alpha.18
iliketomoveit_lifecycle=active
onlyfun_version=0.0.0-alpha.25
onlyfun_version=0.0.0-alpha.27
onlyfun_lifecycle=active
sanctuary_version=0.0.0-alpha.126
sanctuary_version=0.0.0-alpha.139
sanctuary_lifecycle=active
pack_version=26.2.0-alpha.223
zevent_version=0.0.0-alpha.1
zevent_lifecycle=active
pack_version=26.2.0-alpha.233
maven_group=fr.koka99cab.sanctuary26
+66
View File
@@ -0,0 +1,66 @@
# Magic Carpet et Totem de Notch
I Like To Move It possède l'objet, la plateforme temporaire, la remise physique
de la récompense et la compatibilité de l'ancien Totem. Sanctuary reste
propriétaire du jalon mondial et de la réservation unique ; Only Fun possède le
catalogue et applique le jalon avant d'exposer l'offre.
## Identités et provenance
| Rôle | ID | Statut |
|---|---|---|
| objet canonique | `iliketomoveit:magic_carpet` | distribué après Notch puis achetable au Shop |
| Totem historique | `portals:notch_totem` | alias caché, lisible et converti vers l'objet canonique |
| jalon mondial | `sanctuary:notch_defeated` | autorité serveur Sanctuary |
La conversion utilise `ItemStack.transmuteCopy` : quantité, composants et nom
personnalisé sont conservés. Les inventaires joueurs sont traités à la connexion,
les conteneurs vanilla lors de leur chargement et les entités d'item dès leur
tick serveur. Un Totem produit exactement un tapis et les nouveaux mondes ne
distribuent jamais l'ancien ID. La donnée I Like To Move It passe en v4 ; une
pile déjà convertie ne correspond plus à l'alias et ne peut donc pas être
convertie une seconde fois.
Chaque tapis porte une provenance Sanctuary sans autorité de gameplay :
`notch_reward`, `shop_purchase` ou `legacy_notch_totem`. Le serveur ne se fie
jamais à cette étiquette pour autoriser un achat ; elle sert à l'historique, au
diagnostic et à l'infobulle.
## Première récompense
Après la première victoire mondiale, Sanctuary réserve atomiquement la
récompense puis I Like To Move It crée un coffre déterministe près de la maison
de Notch. Le tapis unique est placé au centre et un feu d'artifice signale le
coffre. Sanctuary ne marque la livraison terminée qu'après avoir retrouvé ou
créé ce coffre.
Les états `initial_reward_claimed` et `initial_reward_delivered` sont distincts.
Une reprise entre les deux relance la création de façon idempotente ; un coffre
déjà créé et marqué est reconnu sans recevoir de second tapis. Les anciennes
sauvegardes v1 dont la récompense était déjà déclarée remise sont migrées vers
l'état livré afin de ne rien dupliquer.
## Plateforme temporaire
Tenir le tapis en main principale ou secondaire maintient neuf blocs de verre
sous le joueur. Seul l'air est remplacé : terrain, liquides, plantes et blocs
construits restent intacts. Le joueur conserve ses capacités normales ; seul le
dégât de chute est réinitialisé tant que la plateforme est active.
Chaque position possède un ensemble implicite de propriétaires. Deux tapis
peuvent se chevaucher et le verre ne disparaît qu'après le départ du dernier.
Le nettoyage est effectué côté serveur au relâchement, à la déconnexion, au
changement de dimension et pendant l'arrêt normal du serveur.
## Remplacement au Shop
Only Fun associe le Magic Carpet au resolver public
`SanctuaryWorldMilestones`. Avant `sanctuary:notch_defeated`, l'offre est
absente des snapshots et toute tentative réseau est rejetée par Sanctuary.
Après la victoire, elle rejoint la rotation normale au prix configuré de
1 536 rubis et 128 saphirs : les saphirs débloquent le catalogue, les rubis
paient chaque remplacement et la Mailbox effectue la livraison différée.
Un achat porte la provenance `shop_purchase`, est journalisé comme remplacement
et ne touche jamais aux états de réservation ou de coffre de la récompense
initiale.
+19 -10
View File
@@ -32,6 +32,14 @@ tasks.register("alphaPortalContractSmoke", JavaExec) {
dependsOn tasks.named("testClasses")
}
tasks.register("magicCarpetLedgerSmoke", JavaExec) {
group = "verification"
description = "Checks Magic Carpet overlap, dimension-change and shutdown cleanup ownership."
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetLedgerSmoke"
dependsOn tasks.named("testClasses")
}
tasks.named("test") {
// Runtime contracts are executable smoke tasks, not JUnit engine tests.
failOnNoDiscoveredTests = false
@@ -100,11 +108,11 @@ tasks.register("verifyWaystones") {
if (!missingResources.isEmpty()) throw new GradleException("Missing Waystone resources: ${missingResources}")
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (manifest.custom?.sanctuary26?.data_version != 3
if (manifest.custom?.sanctuary26?.data_version != 4
|| manifest.custom?.sanctuary26?.network_protocol != 5
|| manifest.depends?.anotherworld != ">=0.0.0-alpha.46"
|| manifest.depends?.sanctuary != ">=0.0.0-alpha.21") {
throw new GradleException("I Like To Move It must expose data v3, protocol 5 and its Another World alpha.46/Sanctuary alpha.21 dependencies")
|| manifest.depends?.sanctuary != ">=0.0.0-alpha.127") {
throw new GradleException("I Like To Move It must expose data v4, protocol 5 and its Another World alpha.46/Sanctuary alpha.127 dependencies")
}
["fr_fr", "en_us", "ru_ru"].each { language ->
def translations = new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${language}.json"))
@@ -203,9 +211,9 @@ tasks.register("verifyGrappling") {
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (!manifest.mixins?.any { it.config == "iliketomoveit.mixins.json" }
|| !manifest.mixins?.any { it.config == "iliketomoveit.client.mixins.json" && it.environment == "client" }
|| manifest.custom?.sanctuary26?.data_version != 3
|| manifest.custom?.sanctuary26?.data_version != 4
|| manifest.custom?.sanctuary26?.network_protocol != 5) {
throw new GradleException("I Like To Move It must keep aggregate data v3/protocol 5 and register its common/client mixins")
throw new GradleException("I Like To Move It must expose aggregate data v4/protocol 5 and register its common/client mixins")
}
["fr_fr", "en_us", "ru_ru"].each { language ->
def translations = new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${language}.json"))
@@ -313,7 +321,7 @@ tasks.register("verifyPortals") {
}
def atlasCavernsMigration = new JsonSlurper().parse(rootProject.file(
"pack/migrations/26.2.0-alpha.149-atlas-caverns-to-alpha.150.json"))
if (project.version.toString() != "0.0.0-alpha.16"
if (project.version.toString() != "0.0.0-alpha.18"
|| atlasCavernsMigration.target?.pack_version != "26.2.0-alpha.150"
|| atlasCavernsMigration.target?.modules?.iliketomoveit != "0.0.0-alpha.9"
|| atlasCavernsMigration.portals?.active_routes != [
@@ -349,7 +357,7 @@ tasks.register("verifySilverAircraft") {
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
def migration = new JsonSlurper().parse(rootProject.file(
"pack/migrations/26.2.0-alpha.184-silver-engineering-to-alpha.185.json"))
if (project.version.toString() != "0.0.0-alpha.16"
if (project.version.toString() != "0.0.0-alpha.18"
|| recipe.pattern != ["PGP", "GCG", "PBP"]
|| recipe.key != [B: "anotherworld:silver_block", C: "anotherworld:aircraft_cockpit",
G: "anotherworld:silver_gear", P: "anotherworld:silver_plate"]
@@ -378,7 +386,7 @@ tasks.register("verifyGrapplePrediction") {
def payload = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/network/GrappleStatePayload.java").text
def controller = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/grapple/GrappleController.java").text
def prediction = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/client/grapple/ClientGrapplePrediction.java").text
if (project.version.toString() != "0.0.0-alpha.16"
if (project.version.toString() != "0.0.0-alpha.18"
|| migration.target?.modules?.iliketomoveit != "0.0.0-alpha.13"
|| migration.grappling?.owner_prediction_payload != "iliketomoveit:s2c/grapple_state"
|| migration.compatibility?.iliketomoveit_source_network_protocol != 4
@@ -410,7 +418,7 @@ tasks.register("verifyWaystoneDescendingRelease") {
"src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/waystone/WaystoneElevatorRules.java").text
def teleportation = file(
"src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/network/WaystoneTeleportation.java").text
if (project.version.toString() != "0.0.0-alpha.16"
if (project.version.toString() != "0.0.0-alpha.18"
|| migration.target?.modules?.iliketomoveit != "0.0.0-alpha.14"
|| migration.waystone_elevator?.undiscovered_downward_destination_allowed != true
|| migration.waystone_elevator?.destination_discovered_after_arrival != true
@@ -444,7 +452,7 @@ tasks.register("verifyAlphaPortal") {
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.16"
if (project.version.toString() != "0.0.0-alpha.18"
|| route.schema_version != 2 || route.source_dimension != "minecraft:overworld"
|| route.destination_dimension != "sanctuary:alpha"
|| route.destination_resolver != "fixed"
@@ -466,6 +474,7 @@ tasks.register("verifyAlphaPortal") {
tasks.named("check") {
dependsOn tasks.named("grapplePhysicsSmoke")
dependsOn tasks.named("magicCarpetLedgerSmoke")
dependsOn tasks.named("waystoneElevatorRulesSmoke")
dependsOn tasks.named("happyGhastCruiseSpeedSmoke")
dependsOn tasks.named("verifyWaystones")
@@ -8,6 +8,8 @@ import fr.koka99cab.sanctuary26.iliketomoveit.pulley.PulleyHeadController;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.GrapplingLootTables;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItRegistries;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetController;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigrationService;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.NotchCarpetRewardService;
import fr.koka99cab.sanctuary26.iliketomoveit.waystone.WaystoneElevatorController;
import net.fabricmc.api.ModInitializer;
import net.minecraft.resources.Identifier;
@@ -17,7 +19,7 @@ import org.slf4j.LoggerFactory;
public final class ILikeToMoveItMod implements ModInitializer {
public static final String MOD_ID = "iliketomoveit";
public static final String DISPLAY_NAME = "I Like To Move It";
public static final int CURRENT_DATA_VERSION = 3;
public static final int CURRENT_DATA_VERSION = 4;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public ILikeToMoveItMod() {
@@ -37,6 +39,8 @@ public final class ILikeToMoveItMod implements ModInitializer {
GrappleController.register();
PulleyHeadController.register();
MagicCarpetController.register();
MagicCarpetMigrationService.register();
NotchCarpetRewardService.register();
WaystoneElevatorController.initialize();
LOGGER.info("[{}] Portals, vehicles, grappling, pulleys, Magic Carpet and dimension-local Waystones ready (data v{}, network v{}).",
DISPLAY_NAME, CURRENT_DATA_VERSION, ILikeToMoveItNetworking.PROTOCOL_VERSION);
@@ -0,0 +1,41 @@
package fr.koka99cab.sanctuary26.iliketomoveit.item;
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
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.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
/** Epic carpet with explicit behaviour and provenance in its tooltip. */
public final class MagicCarpetItem extends Item {
public MagicCarpetItem(Properties properties) {
super(properties);
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.description")
.withStyle(ChatFormatting.GRAY));
var origin = SanctuaryItemProvenance.origin(stack);
if (SanctuaryItemProvenance.NOTCH_REWARD.equals(origin)) {
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.notch")
.withStyle(ChatFormatting.GOLD));
} else if (SanctuaryItemProvenance.SHOP_PURCHASE.equals(origin)) {
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.shop")
.withStyle(ChatFormatting.AQUA));
} else if (SanctuaryItemProvenance.LEGACY_NOTCH_TOTEM.equals(origin)) {
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.legacy")
.withStyle(ChatFormatting.DARK_PURPLE));
}
}
@Override
public boolean isFoil(ItemStack stack) {
return SanctuaryItemProvenance.hasOrigin(stack, SanctuaryItemProvenance.NOTCH_REWARD)
|| super.isFoil(stack);
}
}
@@ -0,0 +1,19 @@
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigration;
import net.minecraft.world.Container;
import net.minecraft.world.level.block.entity.BaseContainerBlockEntity;
import net.minecraft.world.level.storage.ValueInput;
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;
/** Converts historical Totems as loaded chests and other vanilla containers are decoded. */
@Mixin(BaseContainerBlockEntity.class)
abstract class MagicCarpetContainerMixin {
@Inject(method = "loadAdditional", at = @At("TAIL"))
private void sanctuary26$migrateNotchTotem(ValueInput input, CallbackInfo ci) {
MagicCarpetMigration.migrateContainer((Container) (Object) this);
}
}
@@ -0,0 +1,21 @@
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigration;
import net.minecraft.world.entity.item.ItemEntity;
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.CallbackInfo;
/** Converts historical Totems already present as loaded item entities. */
@Mixin(ItemEntity.class)
abstract class MagicCarpetItemEntityMixin {
@Inject(method = "tick", at = @At("HEAD"))
private void sanctuary26$migrateNotchTotem(CallbackInfo ci) {
ItemEntity self = (ItemEntity) (Object) this;
ItemStack current = self.getItem();
ItemStack replacement = MagicCarpetMigration.migrateStack(current);
if (replacement != current) self.setItem(replacement);
}
}
@@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.iliketomoveit.registry;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.item.AircraftItem;
import fr.koka99cab.sanctuary26.iliketomoveit.item.GrapplingRodItem;
import fr.koka99cab.sanctuary26.iliketomoveit.item.MagicCarpetItem;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
@@ -25,7 +26,11 @@ public final class ILikeToMoveItItems {
key -> new BoatItem(ILikeToMoveItEntityTypes.MAGIC_CLOUD,
new Item.Properties().stacksTo(1).rarity(Rarity.UNCOMMON).setId(key)));
public static final Item MAGIC_CARPET = register("magic_carpet",
key -> new Item(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
key -> new MagicCarpetItem(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
/** Hidden read-preserving alias for inventories saved by the historical MagicCarpet mod. */
public static final Item LEGACY_NOTCH_TOTEM = register(
Identifier.fromNamespaceAndPath("portals", "notch_totem"),
key -> new MagicCarpetItem(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
public static final Item GRAPPLING_HAND = register("grappling_hand",
key -> new Item(new Item.Properties().setId(key)));
public static final Item GRAPPLING_ROD = register("grappling_rod",
@@ -1,10 +1,7 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@@ -27,7 +24,7 @@ public final class MagicCarpetController {
private static final int RADIUS = 1;
private static final Block CARPET_BLOCK = Blocks.GLASS;
private static final BlockState CARPET_STATE = CARPET_BLOCK.defaultBlockState();
private static final Map<UUID, CarpetState> ACTIVE_CARPETS = new HashMap<>();
private static final MagicCarpetLedger ACTIVE_CARPETS = new MagicCarpetLedger();
private MagicCarpetController() {
}
@@ -45,8 +42,8 @@ public final class MagicCarpetController {
connected.add(id);
boolean active = !player.isSpectator() && (isMagicCarpet(player.getMainHandItem()) || isMagicCarpet(player.getOffhandItem()));
if (active) {
if (!ACTIVE_CARPETS.containsKey(id)) {
ACTIVE_CARPETS.put(id, CarpetState.empty(player.level().dimension()));
if (!ACTIVE_CARPETS.contains(id)) {
ACTIVE_CARPETS.track(id, player.level().dimension(), Set.of());
player.sendOverlayMessage(Component.translatable("message.iliketomoveit.magic_carpet.enabled"));
}
updateCarpet(id, player);
@@ -54,27 +51,26 @@ public final class MagicCarpetController {
deactivate(server, id, true);
}
}
Iterator<Map.Entry<UUID, CarpetState>> iterator = ACTIVE_CARPETS.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<UUID, CarpetState> entry = iterator.next();
if (!connected.contains(entry.getKey())) {
removePositions(server, entry.getKey(), entry.getValue(), entry.getValue().positions());
iterator.remove();
for (UUID owner : ACTIVE_CARPETS.owners()) {
if (!connected.contains(owner)) {
MagicCarpetLedger.State state = ACTIVE_CARPETS.remove(owner);
if (state != null) removePositions(server, owner, state, state.positions());
}
}
}
private static boolean isMagicCarpet(ItemStack stack) {
return !stack.isEmpty() && stack.is(ILikeToMoveItItems.MAGIC_CARPET);
return MagicCarpetMigration.isAnyCarpet(stack);
}
private static void updateCarpet(UUID owner, ServerPlayer player) {
ServerLevel level = player.level();
CarpetState previous = ACTIVE_CARPETS.getOrDefault(owner, CarpetState.empty(level.dimension()));
MagicCarpetLedger.State previous = ACTIVE_CARPETS.state(owner, level.dimension());
Set<BlockPos> target = carpetPositions(player.blockPosition().below(player.isCrouching() ? 2 : 1));
if (!previous.dimension().equals(level.dimension())) {
removePositions(level.getServer(), owner, previous, previous.positions());
previous = CarpetState.empty(level.dimension());
ACTIVE_CARPETS.remove(owner);
previous = ACTIVE_CARPETS.state(owner, level.dimension());
} else {
Set<BlockPos> stale = new HashSet<>(previous.positions());
stale.removeAll(target);
@@ -91,7 +87,7 @@ public final class MagicCarpetController {
placed.add(pos);
}
}
ACTIVE_CARPETS.put(owner, new CarpetState(level.dimension(), Set.copyOf(placed)));
ACTIVE_CARPETS.track(owner, level.dimension(), placed);
player.resetFallDistance();
}
@@ -104,14 +100,14 @@ public final class MagicCarpetController {
}
private static void deactivate(MinecraftServer server, UUID owner, boolean notify) {
CarpetState state = ACTIVE_CARPETS.remove(owner);
MagicCarpetLedger.State state = ACTIVE_CARPETS.remove(owner);
if (state == null) return;
removePositions(server, owner, state, state.positions());
ServerPlayer player = server.getPlayerList().getPlayer(owner);
if (notify && player != null) player.sendOverlayMessage(Component.translatable("message.iliketomoveit.magic_carpet.disabled"));
}
private static void removePositions(MinecraftServer server, UUID owner, CarpetState state, Set<BlockPos> positions) {
private static void removePositions(MinecraftServer server, UUID owner, MagicCarpetLedger.State state, Set<BlockPos> positions) {
ServerLevel level = server.getLevel(state.dimension());
if (level == null) return;
for (BlockPos pos : positions) {
@@ -122,18 +118,13 @@ public final class MagicCarpetController {
}
private static boolean isTrackedByAnother(UUID owner, ResourceKey<Level> dimension, BlockPos pos) {
return ACTIVE_CARPETS.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(owner)
&& entry.getValue().dimension().equals(dimension) && entry.getValue().positions().contains(pos));
return ACTIVE_CARPETS.trackedByAnother(owner, dimension, pos);
}
private static void reset(MinecraftServer server) {
Map<UUID, CarpetState> copy = new HashMap<>(ACTIVE_CARPETS);
ACTIVE_CARPETS.clear();
Map<UUID, MagicCarpetLedger.State> copy = ACTIVE_CARPETS.drain();
copy.forEach((owner, state) -> removePositions(server, owner, state, state.positions()));
ILikeToMoveItMod.LOGGER.debug("Magic Carpet temporary platforms cleaned.");
}
private record CarpetState(ResourceKey<Level> dimension, Set<BlockPos> positions) {
private static CarpetState empty(ResourceKey<Level> dimension) { return new CarpetState(dimension, Set.of()); }
}
}
@@ -0,0 +1,56 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
/** Pure ownership ledger preventing overlapping temporary platforms from deleting each other. */
public final class MagicCarpetLedger {
private final Map<UUID, State> carpets = new HashMap<>();
public boolean contains(UUID owner) {
return carpets.containsKey(owner);
}
public State state(UUID owner, ResourceKey<Level> dimension) {
return carpets.getOrDefault(owner, State.empty(dimension));
}
public void track(UUID owner, ResourceKey<Level> dimension, Set<BlockPos> positions) {
carpets.put(owner, new State(dimension, Set.copyOf(positions)));
}
public State remove(UUID owner) {
return carpets.remove(owner);
}
public Set<UUID> owners() {
return Set.copyOf(carpets.keySet());
}
public boolean trackedByAnother(UUID owner, ResourceKey<Level> dimension, BlockPos pos) {
return carpets.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(owner)
&& entry.getValue().dimension().equals(dimension)
&& entry.getValue().positions().contains(pos));
}
public Map<UUID, State> drain() {
Map<UUID, State> copy = Map.copyOf(carpets);
carpets.clear();
return copy;
}
public record State(ResourceKey<Level> dimension, Set<BlockPos> positions) {
public State {
positions = Set.copyOf(positions);
}
private static State empty(ResourceKey<Level> dimension) {
return new State(dimension, Set.of());
}
}
}
@@ -0,0 +1,39 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
import net.minecraft.world.Container;
import net.minecraft.world.item.ItemStack;
/** Read-preserving conversion from the historical Totem identity to the canonical carpet. */
public final class MagicCarpetMigration {
private MagicCarpetMigration() {
}
public static boolean isAnyCarpet(ItemStack stack) {
return stack != null && !stack.isEmpty()
&& (stack.is(ILikeToMoveItItems.MAGIC_CARPET)
|| stack.is(ILikeToMoveItItems.LEGACY_NOTCH_TOTEM));
}
public static ItemStack migrateStack(ItemStack stack) {
if (stack == null || stack.isEmpty() || !stack.is(ILikeToMoveItItems.LEGACY_NOTCH_TOTEM)) return stack;
ItemStack migrated = stack.transmuteCopy(ILikeToMoveItItems.MAGIC_CARPET, stack.getCount());
SanctuaryItemProvenance.stampIfAbsent(migrated, SanctuaryItemProvenance.LEGACY_NOTCH_TOTEM);
return migrated;
}
public static int migrateContainer(Container container) {
if (container == null) return 0;
int migrated = 0;
for (int slot = 0; slot < container.getContainerSize(); slot++) {
ItemStack current = container.getItem(slot);
ItemStack replacement = migrateStack(current);
if (replacement == current) continue;
container.setItem(slot, replacement);
migrated++;
}
if (migrated > 0) container.setChanged();
return migrated;
}
}
@@ -0,0 +1,34 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.data.ILikeToMoveItData;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
/** Converts online inventories once their historical alias has been decoded. */
public final class MagicCarpetMigrationService {
private MagicCarpetMigrationService() {
}
public static void register() {
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> migrate(handler.player));
ServerTickEvents.END_SERVER_TICK.register(MagicCarpetMigrationService::tick);
}
private static void tick(MinecraftServer server) {
ILikeToMoveItData.get(server);
if (server.overworld().getGameTime() % 20L != 0L) return;
for (ServerPlayer player : server.getPlayerList().getPlayers()) migrate(player);
}
private static void migrate(ServerPlayer player) {
int migrated = MagicCarpetMigration.migrateContainer(player.getInventory());
migrated += MagicCarpetMigration.migrateContainer(player.getEnderChestInventory());
if (migrated > 0) {
ILikeToMoveItMod.LOGGER.info("[Magic Carpet] Migrated {} historical Totem stack(s) for {}.",
migrated, player.getGameProfile().name());
}
}
}
@@ -0,0 +1,124 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld;
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import java.util.ArrayList;
import java.util.List;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.entity.projectile.FireworkRocketEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.component.FireworkExplosion;
import net.minecraft.world.item.component.Fireworks;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
/** Idempotently materializes the globally reserved Magic Carpet in Alpha. */
public final class NotchCarpetRewardService {
private static final int REWARD_SLOT = 13;
private NotchCarpetRewardService() {
}
public static void register() {
ServerTickEvents.END_SERVER_TICK.register(NotchCarpetRewardService::tick);
}
private static void tick(MinecraftServer server) {
if (server.overworld().getGameTime() % 20L != 0L) return;
var state = SanctuaryNotchMilestone.snapshot(server);
if (!state.defeated() || state.initialRewardDelivered()) return;
if (!state.initialRewardClaimed() && !SanctuaryNotchMilestone.claimInitialReward(server)) return;
ServerLevel alpha = server.getLevel(SanctuaryDimensions.ALPHA);
if (alpha == null) return;
BlockPos home = SanctuaryAlphaWorld.residence(alpha.getSeed());
if (!alpha.isLoaded(home)) return;
RewardChest result = ensureRewardChest(alpha, home);
if (result == null || !SanctuaryNotchMilestone.completeInitialReward(server)) return;
if (result.created()) {
launchFirework(alpha, result.pos());
server.getPlayerList().broadcastSystemMessage(
Component.translatable("message.iliketomoveit.magic_carpet.reward_created"), false);
}
ILikeToMoveItMod.LOGGER.info(
"[Magic Carpet] Completed unique Notch reward at {} (origin=notch_reward, recovered={}).",
result.pos().toShortString(), !result.created());
}
private static RewardChest ensureRewardChest(ServerLevel level, BlockPos home) {
List<BlockPos> candidates = candidates(home);
for (BlockPos pos : candidates) {
if (containsInitialReward(level, pos)) return new RewardChest(pos, false);
}
for (BlockPos pos : candidates) {
if (!level.getBlockState(pos).isAir() || !level.getBlockState(pos.above()).isAir()
|| level.getBlockState(pos.below()).isAir()) continue;
if (!level.setBlock(pos, Blocks.CHEST.defaultBlockState(), Block.UPDATE_ALL)) continue;
if (level.getBlockEntity(pos) instanceof ChestBlockEntity chest) {
chest.setItem(REWARD_SLOT, rewardStack());
chest.setChanged();
level.sendBlockUpdated(pos, level.getBlockState(pos), level.getBlockState(pos), Block.UPDATE_ALL);
return new RewardChest(pos, true);
}
level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
}
return null;
}
private static boolean containsInitialReward(ServerLevel level, BlockPos pos) {
if (level.getBlockState(pos).getBlock() != Blocks.CHEST
|| !(level.getBlockEntity(pos) instanceof Container container)) return false;
for (ItemStack stack : container) {
if (stack.is(ILikeToMoveItItems.MAGIC_CARPET)
&& SanctuaryItemProvenance.hasOrigin(stack, SanctuaryItemProvenance.NOTCH_REWARD)) return true;
}
return false;
}
private static ItemStack rewardStack() {
ItemStack reward = new ItemStack(ILikeToMoveItItems.MAGIC_CARPET);
reward.set(DataComponents.CUSTOM_NAME,
Component.translatable("item.iliketomoveit.magic_carpet.notch_reward_name"));
SanctuaryItemProvenance.stamp(reward, SanctuaryItemProvenance.NOTCH_REWARD);
return reward;
}
private static List<BlockPos> candidates(BlockPos home) {
ArrayList<BlockPos> positions = new ArrayList<>();
for (int y : new int[] {0, 1, -1, 2}) {
for (int radius = 2; radius <= 7; radius++) {
for (int x = -radius; x <= radius; x++) for (int z = -radius; z <= radius; z++) {
if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue;
BlockPos candidate = home.offset(x, y, z).immutable();
if (SanctuaryAlphaWorld.containsHorizontal(candidate)) positions.add(candidate);
}
}
}
return List.copyOf(positions);
}
private static void launchFirework(ServerLevel level, BlockPos pos) {
ItemStack rocket = new ItemStack(Items.FIREWORK_ROCKET);
FireworkExplosion explosion = new FireworkExplosion(FireworkExplosion.Shape.STAR,
new IntArrayList(new int[] {0xE8B923, 0xF1F1F1}),
new IntArrayList(new int[] {0x9B59B6}), true, true);
rocket.set(DataComponents.FIREWORKS, new Fireworks(1, List.of(explosion)));
level.addFreshEntity(new FireworkRocketEntity(level, pos.getX() + 0.5D,
pos.getY() + 1.2D, pos.getZ() + 0.5D, rocket));
}
private record RewardChest(BlockPos pos, boolean created) {
}
}
@@ -3,7 +3,13 @@
"itemGroup.iliketomoveit.transport": "I Like To Move It",
"item.iliketomoveit.aircraft": "Aircraft",
"item.iliketomoveit.magic_cloud": "Magic Cloud",
"item.iliketomoveit.magic_carpet": "Magic Carpet (laboratory)",
"item.iliketomoveit.magic_carpet": "Magic Carpet",
"item.portals.notch_totem": "Notch Totem (legacy)",
"item.iliketomoveit.magic_carpet.notch_reward_name": "Notch's Magic Carpet",
"item.iliketomoveit.magic_carpet.description": "Hold it to maintain a temporary 3 × 3 platform beneath your feet.",
"item.iliketomoveit.magic_carpet.origin.notch": "Unique world reward from Notch",
"item.iliketomoveit.magic_carpet.origin.shop": "Replacement purchased from the Shop",
"item.iliketomoveit.magic_carpet.origin.legacy": "Converted from the historical Notch Totem",
"block.iliketomoveit.waystone": "Waystone",
"block.waystone.waystone": "Waystone (legacy)",
"entity.iliketomoveit.aircraft": "Aircraft",
@@ -11,6 +17,7 @@
"container.iliketomoveit.aircraft_engine": "Aircraft Engine",
"message.iliketomoveit.magic_carpet.enabled": "Magic Carpet enabled",
"message.iliketomoveit.magic_carpet.disabled": "Magic Carpet disabled",
"message.iliketomoveit.magic_carpet.reward_created": "Notch's unique Magic Carpet appeared in a chest near his house.",
"subtitles.iliketomoveit.magic_cloud.fly": "Magic Cloud flies",
"screen.iliketomoveit.waystone.title": "Waystones",
"screen.iliketomoveit.waystone.empty": "No other waystone in this dimension",
@@ -3,7 +3,13 @@
"itemGroup.iliketomoveit.transport": "I Like To Move It",
"item.iliketomoveit.aircraft": "Avion",
"item.iliketomoveit.magic_cloud": "Nuage magique",
"item.iliketomoveit.magic_carpet": "Tapis magique (laboratoire)",
"item.iliketomoveit.magic_carpet": "Tapis magique",
"item.portals.notch_totem": "Totem de Notch (hérité)",
"item.iliketomoveit.magic_carpet.notch_reward_name": "Tapis magique de Notch",
"item.iliketomoveit.magic_carpet.description": "Tenu en main, il maintient une plateforme temporaire 3 × 3 sous vos pieds.",
"item.iliketomoveit.magic_carpet.origin.notch": "Récompense mondiale unique de Notch",
"item.iliketomoveit.magic_carpet.origin.shop": "Remplacement acheté au Shop",
"item.iliketomoveit.magic_carpet.origin.legacy": "Converti depuis le Totem de Notch historique",
"block.iliketomoveit.waystone": "Pierre de voyage",
"block.waystone.waystone": "Pierre de voyage (héritée)",
"entity.iliketomoveit.aircraft": "Avion",
@@ -11,6 +17,7 @@
"container.iliketomoveit.aircraft_engine": "Moteur de l'avion",
"message.iliketomoveit.magic_carpet.enabled": "Tapis magique activé",
"message.iliketomoveit.magic_carpet.disabled": "Tapis magique désactivé",
"message.iliketomoveit.magic_carpet.reward_created": "Le Tapis magique unique de Notch est apparu dans un coffre près de sa maison.",
"subtitles.iliketomoveit.magic_cloud.fly": "Le nuage magique vole",
"screen.iliketomoveit.waystone.title": "Pierres de voyage",
"screen.iliketomoveit.waystone.empty": "Aucune autre pierre dans cette dimension",
@@ -3,7 +3,13 @@
"itemGroup.iliketomoveit.transport": "I Like To Move It",
"item.iliketomoveit.aircraft": "Самолёт",
"item.iliketomoveit.magic_cloud": "Волшебное облако",
"item.iliketomoveit.magic_carpet": "Ковёр-самолёт (лаборатория)",
"item.iliketomoveit.magic_carpet": "Ковёр-самолёт",
"item.portals.notch_totem": "Тотем Нотча (старый)",
"item.iliketomoveit.magic_carpet.notch_reward_name": "Ковёр-самолёт Нотча",
"item.iliketomoveit.magic_carpet.description": "В руке поддерживает временную платформу 3 × 3 под ногами.",
"item.iliketomoveit.magic_carpet.origin.notch": "Уникальная мировая награда за Нотча",
"item.iliketomoveit.magic_carpet.origin.shop": "Замена, купленная в Магазине",
"item.iliketomoveit.magic_carpet.origin.legacy": "Преобразован из исторического Тотема Нотча",
"block.iliketomoveit.waystone": "Камень пути",
"block.waystone.waystone": "Камень пути (старый)",
"entity.iliketomoveit.aircraft": "Самолёт",
@@ -11,6 +17,7 @@
"container.iliketomoveit.aircraft_engine": "Двигатель самолёта",
"message.iliketomoveit.magic_carpet.enabled": "Ковёр-самолёт включён",
"message.iliketomoveit.magic_carpet.disabled": "Ковёр-самолёт выключен",
"message.iliketomoveit.magic_carpet.reward_created": "Уникальный ковёр-самолёт Нотча появился в сундуке возле его дома.",
"subtitles.iliketomoveit.magic_cloud.fly": "Волшебное облако летит",
"screen.iliketomoveit.waystone.title": "Камни пути",
"screen.iliketomoveit.waystone.empty": "В этом измерении нет других камней",
@@ -1,6 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "minecraft:block/purple_wool"
"layer0": "iliketomoveit:item/magic_carpet"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 366 B

@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "iliketomoveit:item/magic_carpet"
}
}
@@ -33,13 +33,13 @@
"java": ">=25",
"fabric-api": ">=0.154.2+26.2",
"anotherworld": ">=0.0.0-alpha.46",
"sanctuary": ">=0.0.0-alpha.21"
"sanctuary": ">=0.0.0-alpha.127"
},
"custom": {
"sanctuary26": {
"system_type": "gameplay-system",
"namespace": "iliketomoveit",
"data_version": 3,
"data_version": 4,
"network_protocol": 5,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
@@ -3,7 +3,9 @@
"package": "fr.koka99cab.sanctuary26.iliketomoveit.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"HappyGhastMixin"
"HappyGhastMixin",
"MagicCarpetContainerMixin",
"MagicCarpetItemEntityMixin"
],
"injectors": {
"defaultRequire": 1
@@ -0,0 +1,45 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
/** Regression checks for overlap, world changes and shutdown cleanup ownership. */
public final class MagicCarpetLedgerSmoke {
private MagicCarpetLedgerSmoke() {
}
public static void main(String[] args) {
MagicCarpetLedger ledger = new MagicCarpetLedger();
UUID first = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID second = UUID.fromString("00000000-0000-0000-0000-000000000002");
ResourceKey<Level> firstWorld = ResourceKey.create(Registries.DIMENSION,
Identifier.fromNamespaceAndPath("test", "first"));
ResourceKey<Level> secondWorld = ResourceKey.create(Registries.DIMENSION,
Identifier.fromNamespaceAndPath("test", "second"));
BlockPos shared = new BlockPos(4, 70, 4);
ledger.track(first, firstWorld, Set.of(shared));
ledger.track(second, firstWorld, Set.of(shared));
if (!ledger.trackedByAnother(first, firstWorld, shared)) {
throw new AssertionError("Overlapping carpets lost their second owner");
}
ledger.remove(first);
if (ledger.trackedByAnother(second, firstWorld, shared)) {
throw new AssertionError("Removed owner still controls the shared glass");
}
ledger.track(second, secondWorld, Set.of(new BlockPos(0, 64, 0)));
if (!ledger.state(second, secondWorld).dimension().equals(secondWorld)
|| !ledger.state(second, firstWorld).dimension().equals(secondWorld)) {
throw new AssertionError("Dimension change did not replace the tracked world");
}
Map<UUID, MagicCarpetLedger.State> shutdown = ledger.drain();
if (shutdown.size() != 1 || !ledger.owners().isEmpty()) {
throw new AssertionError("Server shutdown did not atomically drain temporary platforms");
}
}
}
+6 -6
View File
@@ -565,7 +565,7 @@ tasks.register("verifyItsAlive") {
def climateSource = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/ClimateCropForaging.java").text
def pineappleSource = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/PineappleItem.java").text
def worldgenSource = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/ItsAliveAgricultureWorldGeneration.java").text
if (project.version.toString() != "0.0.0-alpha.37"
if (project.version.toString() != "0.0.0-alpha.38"
|| climateMigration.target?.pack_version != "26.2.0-alpha.157"
|| climateMigration.target?.modules?.itsalive != "0.0.0-alpha.24"
|| !climateSource.contains("PlayerBlockBreakEvents.AFTER.register")
@@ -1322,7 +1322,7 @@ tasks.register("verifyCulinaryReliability") {
}
}
}
if (project.version.toString() != "0.0.0-alpha.37"
if (project.version.toString() != "0.0.0-alpha.38"
|| migration.target?.modules?.itsalive != "0.0.0-alpha.28"
|| migration.culinary_fixes?.food_press_contents_destroyed_on_break != true
|| migration.culinary_fixes?.comparator_contract?.blocks?.size() != 6
@@ -1360,7 +1360,7 @@ tasks.register("verifyCauldronBatching") {
def recipe = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/recipe/CulinaryProcessRecipe.java").text
def cauldron = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/block/entity/CookingCauldronBlockEntity.java").text
def timed = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/block/entity/AbstractTimedCulinaryBlockEntity.java").text
if (project.version.toString() != "0.0.0-alpha.37"
if (project.version.toString() != "0.0.0-alpha.38"
|| migration.target?.modules?.itsalive != "0.0.0-alpha.29"
|| migration.cauldron_batches?.logical_stack_limit != 64
|| migration.cauldron_batches?.identity != "same_item_same_components"
@@ -1398,7 +1398,7 @@ tasks.register("verifyFermentationBarrelAutomation") {
def partialMatching = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/recipe/CulinaryPartialMatching.java").text
def blockstate = new JsonSlurper().parse(
file("src/main/resources/assets/itsalive/blockstates/fermentation_barrel.json"))
if (project.version.toString() != "0.0.0-alpha.37"
if (project.version.toString() != "0.0.0-alpha.38"
|| migration.source?.modules?.itsalive != "0.0.0-alpha.29"
|| migration.target?.modules?.itsalive != "0.0.0-alpha.30"
|| migration.fermentation_barrel?.maximum_units_per_input_slot != 1
@@ -1446,7 +1446,7 @@ tasks.register("verifyCulinaryLecternRelease") {
[(locale): new JsonSlurper().parse(file(
"src/main/resources/assets/itsalive/lang/${locale}.json"))]
}
if (project.version.toString() != "0.0.0-alpha.37"
if (project.version.toString() != "0.0.0-alpha.38"
|| migration.target?.modules?.itsalive != "0.0.0-alpha.31"
|| migration.culinary_lectern?.book_tag != "minecraft:lectern_books"
|| migration.culinary_lectern?.owner_catalog_shared_while_placed != true
@@ -1509,7 +1509,7 @@ tasks.register("verifyCanapliaForbiddenBook") {
fr_fr: "Livre interdit",
en_us: "Forbidden Book",
ru_ru: "Запретная книга"]
if (project.version.toString() != "0.0.0-alpha.37"
if (project.version.toString() != "0.0.0-alpha.38"
|| !pageItem.contains("CanapliaKnowledge.learnRandomUnknown(serverPlayer)")
|| !knowledge.contains("unknownVariants(data.personal(player.getUUID()))")
|| !knowledge.contains("data.personal(inscription.ownerId())")
@@ -34,7 +34,8 @@ public final class CanapliaHarvestService {
public static void onMatureHarvest(ServerLevel level, ServerPlayer player, BlockPos pos, BlockState state) {
if (!state.is(CanapliaContent.HEMP_CROP) || !CanapliaContent.HEMP_CROP.isMaxAge(state)) return;
CanapliaVariantKey parent = state.getValue(CanapliaHempCropBlock.VARIANT);
CanapliaVariantKey parent = CanapliaVariantLifecycle.harvestedFromPlant(
state.getValue(CanapliaHempCropBlock.VARIANT));
CanapliaKnowledge.discover(player, parent, "harvest");
ClimateCropForaging.CropClimate climate = ClimateCropForaging.climate(level.getBiome(pos));
AmbianceLunarHarvestApi.Conditions lunar = AmbianceLunarHarvestApi.evaluate(level, pos);
@@ -65,7 +66,8 @@ public final class CanapliaHarvestService {
if (pos.equals(center)) continue;
BlockState state = level.getBlockState(pos);
if (state.is(CanapliaContent.HEMP_CROP) && CanapliaContent.HEMP_CROP.isMaxAge(state)) {
variants.add(state.getValue(CanapliaHempCropBlock.VARIANT));
variants.add(CanapliaVariantLifecycle.harvestedFromPlant(
state.getValue(CanapliaHempCropBlock.VARIANT)));
}
}
return Set.copyOf(variants);
@@ -0,0 +1,36 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
/** Stable variant hand-off across placement, growth, block-state persistence and harvest. */
public final class CanapliaVariantLifecycle {
private CanapliaVariantLifecycle() {
}
public static CanapliaVariantKey plantedFromSeed(CanapliaVariantKey seedVariant) {
return require(seedVariant);
}
public static CanapliaVariantKey afterGrowth(CanapliaVariantKey currentVariant) {
return require(currentVariant);
}
public static CanapliaVariantKey upperFromLower(CanapliaVariantKey lowerVariant) {
return require(lowerVariant);
}
public static CanapliaVariantKey harvestedFromPlant(CanapliaVariantKey plantVariant) {
return require(plantVariant);
}
public static String persistedKey(CanapliaVariantKey variant) {
return require(variant).key();
}
public static CanapliaVariantKey fromPersistedKey(String key) {
return CanapliaVariantKey.byKey(key).orElse(CanapliaVariantKey.COMMON);
}
private static CanapliaVariantKey require(CanapliaVariantKey variant) {
if (variant == null) throw new IllegalArgumentException("Canaplia variant cannot be null");
return variant;
}
}
@@ -2,6 +2,7 @@ package fr.koka99cab.sanctuary26.itsalive.canaplia.block;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaContent;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaHarvestService;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantLifecycle;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantKey;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
@@ -43,19 +44,28 @@ public final class CanapliaHempCropBlock extends CropBlock {
@Override
protected ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state, boolean includeData) {
return new ItemStack(CanapliaContent.family(state.getValue(VARIANT)).seeds());
CanapliaVariantKey variant = CanapliaVariantLifecycle.harvestedFromPlant(state.getValue(VARIANT));
return new ItemStack(CanapliaContent.family(variant).seeds());
}
@Override
protected void randomTick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
super.randomTick(state, level, pos, random);
synchronizeUpper(level, pos, level.getBlockState(pos));
if (level.getRawBrightness(pos, 0) < 9) return;
int age = getAge(state);
if (age >= getMaxAge()) return;
float speed = getGrowthSpeed(this, level, pos);
if (random.nextInt((int) (25.0F / speed) + 1) != 0) return;
BlockState grown = state.setValue(AGE, age + 1).setValue(VARIANT,
CanapliaVariantLifecycle.afterGrowth(state.getValue(VARIANT)));
level.setBlock(pos, grown, Block.UPDATE_CLIENTS);
synchronizeUpper(level, pos, grown);
}
@Override
public void growCrops(Level level, BlockPos pos, BlockState state) {
int age = Math.min(getMaxAge(), getAge(state) + getBonemealAgeIncrease(level));
BlockState grown = state.setValue(AGE, age);
BlockState grown = state.setValue(AGE, age).setValue(VARIANT,
CanapliaVariantLifecycle.afterGrowth(state.getValue(VARIANT)));
level.setBlock(pos, grown, Block.UPDATE_CLIENTS);
synchronizeUpper(level, pos, grown);
}
@@ -78,7 +88,7 @@ public final class CanapliaHempCropBlock extends CropBlock {
if (!(level instanceof ServerLevel serverLevel) || !(player instanceof ServerPlayer serverPlayer)) {
return InteractionResult.FAIL;
}
CanapliaVariantKey variant = state.getValue(VARIANT);
CanapliaVariantKey variant = CanapliaVariantLifecycle.harvestedFromPlant(state.getValue(VARIANT));
Block.popResource(serverLevel, pos,
new ItemStack(CanapliaContent.family(variant).bud(), 1 + serverLevel.getRandom().nextInt(3)));
CanapliaHarvestService.onMatureHarvest(serverLevel, serverPlayer, pos, state);
@@ -97,7 +107,8 @@ public final class CanapliaHempCropBlock extends CropBlock {
if (getAge(state) >= UPPER_START_AGE) {
if (aboveState.isAir() || aboveState.is(CanapliaContent.HEMP_TOP)) {
level.setBlock(above, CanapliaContent.HEMP_TOP.defaultBlockState()
.setValue(AGE, getAge(state)).setValue(VARIANT, state.getValue(VARIANT)), Block.UPDATE_CLIENTS);
.setValue(AGE, getAge(state)).setValue(VARIANT,
CanapliaVariantLifecycle.upperFromLower(state.getValue(VARIANT))), Block.UPDATE_CLIENTS);
}
} else if (aboveState.is(CanapliaContent.HEMP_TOP)) {
level.removeBlock(above, false);
@@ -1,6 +1,7 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia.block;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaContent;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantLifecycle;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantKey;
import net.minecraft.core.BlockPos;
import net.minecraft.world.item.ItemStack;
@@ -26,7 +27,9 @@ public final class CanapliaHempTopBlock extends CropBlock {
@Override
protected ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state, boolean includeData) {
return new ItemStack(CanapliaContent.family(state.getValue(CanapliaHempCropBlock.VARIANT)).seeds());
CanapliaVariantKey variant = CanapliaVariantLifecycle.harvestedFromPlant(
state.getValue(CanapliaHempCropBlock.VARIANT));
return new ItemStack(CanapliaContent.family(variant).seeds());
}
@Override
@@ -1,5 +1,6 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia.item;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantLifecycle;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantKey;
import fr.koka99cab.sanctuary26.itsalive.canaplia.block.CanapliaHempCropBlock;
import fr.koka99cab.sanctuary26.itsalive.canaplia.block.CanapliaResinBlock;
@@ -24,12 +25,17 @@ public final class CanapliaVariantBlockItem extends BlockItem {
@Override
protected BlockState getPlacementState(BlockPlaceContext context) {
BlockState state = super.getPlacementState(context);
if (state == null) return null;
return applyVariant(state, variant);
}
public static BlockState applyVariant(BlockState state, CanapliaVariantKey variant) {
if (state == null || variant == null) return state;
CanapliaVariantKey placed = CanapliaVariantLifecycle.plantedFromSeed(variant);
if (state.hasProperty(CanapliaHempCropBlock.VARIANT)) {
return state.setValue(CanapliaHempCropBlock.VARIANT, variant);
return state.setValue(CanapliaHempCropBlock.VARIANT, placed);
}
if (state.hasProperty(CanapliaResinBlock.VARIANT)) {
return state.setValue(CanapliaResinBlock.VARIANT, variant);
return state.setValue(CanapliaResinBlock.VARIANT, placed);
}
return state;
}
@@ -61,6 +61,22 @@ public final class CanapliaGameplaySmoke {
Set.of(), Temperature.COLD, Humidity.BALANCED, 12, false, false, true, true, false)).isEmpty(),
"lunar harvest requires a sky dimension");
for (CanapliaVariantKey variant : CanapliaVariantKey.values()) {
CanapliaVariantKey placed = CanapliaVariantLifecycle.plantedFromSeed(variant);
assertEquals(variant, placed,
"historical seed placement variant " + variant.key());
CanapliaVariantKey grown = CanapliaVariantLifecycle.afterGrowth(placed);
assertEquals(variant, grown,
"natural growth variant " + variant.key());
CanapliaVariantKey persisted = CanapliaVariantLifecycle.fromPersistedKey(
CanapliaVariantLifecycle.persistedKey(grown));
assertEquals(variant, persisted, "persisted variant " + variant.key());
assertEquals(variant, CanapliaVariantLifecycle.upperFromLower(persisted),
"upper crop variant " + variant.key());
assertEquals(variant, CanapliaVariantLifecycle.harvestedFromPlant(persisted),
"harvest variant " + variant.key());
}
CanapliaEffectRules.Plan minimum = CanapliaEffectRules.plan(0, 1, 0, 0);
assertEquals(CanapliaEffectRules.MIN_PRIMARY_TICKS, minimum.primaryTicks(), "minimum primary duration");
assertTrue(!minimum.badTrip(), "nonzero bad-trip roll");
@@ -48,7 +48,7 @@ public final class OnlyFunMod implements ModInitializer {
OnlyFunCommands.register();
AbjectionActions.initialize();
AbjectionCommands.register();
LOGGER.info("[{}] Loot generator, natural Lucky Blocks, mob lootboxes and chicken rules ready (data v{}, network v{}).",
LOGGER.info("[{}] Loot generator, boss-gated Shop, natural Lucky Blocks, mob lootboxes and chicken rules ready (data v{}, network v{}).",
DISPLAY_NAME, CURRENT_DATA_VERSION, OnlyFunNetworking.PROTOCOL_VERSION);
}
}
@@ -2,6 +2,7 @@ package fr.koka99cab.sanctuary26.onlyfun.loot;
import fr.koka99cab.sanctuary26.onlyfun.OnlyFunMod;
import fr.koka99cab.sanctuary26.onlyfun.registry.OnlyFunRegistries;
import fr.koka99cab.sanctuary26.sanctuary.api.entity.SanctuaryEntityPolicy;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
@@ -38,7 +39,8 @@ public final class OnlyFunLootboxDrops {
}
private static void afterDeath(LivingEntity entity, DamageSource source) {
if (!(entity instanceof Mob mob) || !(entity.level() instanceof ServerLevel level)) return;
if (SanctuaryEntityPolicy.isTemporaryCompanion(entity)
|| !(entity instanceof Mob mob) || !(entity.level() instanceof ServerLevel level)) return;
ServerPlayer killer = playerKiller(entity, source);
if (killer == null || killer.isSpectator()) return;
@@ -1,29 +1,22 @@
package fr.koka99cab.sanctuary26.onlyfun.shop;
import java.util.Objects;
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
import fr.koka99cab.sanctuary26.sanctuary.api.progression.SanctuaryWorldMilestones;
import java.util.Map;
import java.util.Set;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
/** Keeps exceptional boss rewards out of the Shop until their future boss owner unlocks them. */
/** Keeps exceptional boss rewards out of the Shop until their public world milestone is reached. */
public final class OnlyFunBossShopGate {
public static final Identifier MAGIC_CLOUD = Identifier.fromNamespaceAndPath("iliketomoveit", "magic_cloud");
public static final Identifier MAGIC_CARPET = Identifier.fromNamespaceAndPath("iliketomoveit", "magic_carpet");
private static final Set<Identifier> BOSS_REWARDS = Set.of(MAGIC_CLOUD, MAGIC_CARPET);
private static final BossUnlockResolver LOCKED = (server, reward) -> false;
private static volatile BossUnlockResolver resolver = LOCKED;
private static final Map<Identifier, Identifier> IMPLEMENTED_MILESTONES = Map.of(
MAGIC_CARPET, SanctuaryNotchMilestone.NOTCH_DEFEATED);
private OnlyFunBossShopGate() {}
/** Called later by the module that owns the bosses and their persistent defeat state. */
public static synchronized void registerUnlockResolver(BossUnlockResolver value) {
Objects.requireNonNull(value, "value");
if (resolver != LOCKED && resolver != value) {
throw new IllegalStateException("A boss Shop unlock resolver is already registered");
}
resolver = value;
}
public static boolean requiresBoss(String itemId) {
Identifier id = Identifier.tryParse(itemId);
return id != null && BOSS_REWARDS.contains(id);
@@ -31,11 +24,13 @@ public final class OnlyFunBossShopGate {
public static boolean isAvailable(MinecraftServer server, String itemId) {
Identifier id = Identifier.tryParse(itemId);
return id == null || !BOSS_REWARDS.contains(id) || resolver.isUnlocked(server, id);
if (id == null || !BOSS_REWARDS.contains(id)) return true;
Identifier milestone = requiredMilestone(id);
return milestone != null && SanctuaryWorldMilestones.isReached(server, milestone);
}
@FunctionalInterface
public interface BossUnlockResolver {
boolean isUnlocked(MinecraftServer server, Identifier rewardItem);
/** Returns null while the corresponding boss contract is not implemented. */
public static Identifier requiredMilestone(Identifier rewardItem) {
return IMPLEMENTED_MILESTONES.get(rewardItem);
}
}
+2 -2
View File
@@ -26,8 +26,8 @@
"minecraft": "~26.2",
"java": ">=25",
"fabric-api": ">=0.154.2+26.2",
"iliketomoveit": "*",
"sanctuary": "*"
"iliketomoveit": ">=0.0.0-alpha.17",
"sanctuary": ">=0.0.0-alpha.129"
},
"custom": {
"sanctuary26": {
@@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.onlyfun.shop;
import fr.koka99cab.sanctuary26.onlyfun.policy.OnlyFunItemPolicy;
import fr.koka99cab.sanctuary26.sanctuary.shop.ShopOffer;
import fr.koka99cab.sanctuary26.sanctuary.shop.ShopOfferCatalog;
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -94,12 +95,12 @@ public final class OnlyFunShopCatalogSmoke {
"Magic Cloud must remain boss-gated");
require(!OnlyFunBossShopGate.isAvailable(null, "iliketomoveit:magic_carpet")
&& !OnlyFunBossShopGate.isAvailable(null, "iliketomoveit:magic_cloud"),
"Boss rewards must be unavailable while their bosses do not exist");
OnlyFunBossShopGate.registerUnlockResolver((server, reward) ->
reward.equals(OnlyFunBossShopGate.MAGIC_CARPET));
require(OnlyFunBossShopGate.isAvailable(null, "iliketomoveit:magic_carpet")
&& !OnlyFunBossShopGate.isAvailable(null, "iliketomoveit:magic_cloud"),
"Each future boss must unlock only its corresponding reward");
"Boss rewards must reject lookups without authoritative server state");
require(SanctuaryNotchMilestone.NOTCH_DEFEATED.equals(
OnlyFunBossShopGate.requiredMilestone(OnlyFunBossShopGate.MAGIC_CARPET)),
"Magic Carpet must resolve the public Notch milestone");
require(OnlyFunBossShopGate.requiredMilestone(OnlyFunBossShopGate.MAGIC_CLOUD) == null,
"Magic Cloud must stay locked until its own boss is implemented");
for (String protectedId : List.of(
"anotherworld:ruby", "anotherworld:ruby_block", "anotherworld:deepslate_ruby_ore",
@@ -0,0 +1,129 @@
{
"schema_version": 1,
"status": "released",
"source": {
"pack_version": "26.2.0-alpha.223",
"modules": {
"itsalive": "0.0.0-alpha.36",
"ouch": "0.0.0-alpha.10",
"masterkey": "0.0.0-alpha.8",
"iliketomoveit": "0.0.0-alpha.16",
"onlyfun": "0.0.0-alpha.25",
"sanctuary": "0.0.0-alpha.125"
}
},
"target": {
"pack_version": "26.2.0-alpha.224",
"modules": {
"itsalive": "0.0.0-alpha.37",
"ouch": "0.0.0-alpha.11",
"masterkey": "0.0.0-alpha.9",
"iliketomoveit": "0.0.0-alpha.17",
"onlyfun": "0.0.0-alpha.26",
"sanctuary": "0.0.0-alpha.128"
}
},
"inventory_sort": {
"owner": "sanctuary",
"payload_container_flag": "bounded_boolean",
"sortable_storage_menus": ["chest", "barrel", "shulker_box", "hopper", "dispenser"],
"player_inventory_when_hovered": true,
"hotbar_unchanged": true,
"hotbar_restored_to_bottom_on_login": true,
"hotbar_restored_to_bottom_after_row_purchase": true,
"excluded_menus": ["furnace", "shop", "redstoner_storage_terminal"],
"server_authoritative": true,
"server_validates_open_menu": true
},
"notch_encounter": {
"entity_owner": "itsalive",
"combat_owner": "ouch",
"milestone_owner": "sanctuary",
"diagnostics_owner": "masterkey",
"dimension": "sanctuary:alpha",
"unique_persistent_resident": true,
"legacy_entity": "portals:notch",
"legacy_entity_migrated": true,
"maximum_participants": 8,
"first_transformation_breaks_blocks": true,
"later_attacks_break_blocks": false,
"returned_fireballs_supported": true,
"abandoned_fight_resets": true,
"global_victory_monotonic": true,
"atlas_boss_marker_for_participants": true,
"commands": ["/masterkey notch inspect", "/masterkey notch restore"]
},
"magic_carpet": {
"owner": "iliketomoveit",
"canonical_item": "iliketomoveit:magic_carpet",
"legacy_item": "portals:notch_totem",
"legacy_alias_hidden": true,
"legacy_components_preserved": true,
"legacy_custom_name_preserved": true,
"legacy_locations": ["player_inventory", "container", "item_entity"],
"one_legacy_stack_becomes_one_canonical_stack": true,
"new_worlds_distribute_legacy_item": false,
"temporary_platform": {
"size": [3, 1, 3],
"block": "minecraft:glass",
"air_only_placement": true,
"creative_flight_granted": false,
"overlap_owner_tracking": true,
"cleanup": ["deactivation", "disconnect", "dimension_change", "server_stop"]
}
},
"notch_reward": {
"milestone_owner": "sanctuary",
"milestone": "sanctuary:notch_defeated",
"reservation": "initial_reward_claimed",
"completion": "initial_reward_delivered",
"delivery_owner": "iliketomoveit",
"delivery": "deterministic_chest_near_notch_residence",
"firework_signal": true,
"idempotent_recovery": true,
"maximum_canonical_initial_rewards": 1,
"provenance": "sanctuary:notch_reward"
},
"shop_replacement": {
"catalog_owner": "onlyfun",
"transaction_owner": "sanctuary",
"unlock_resolver": "SanctuaryWorldMilestones",
"hidden_before_notch": true,
"server_authoritative_gate": true,
"configured_ruby_price": 1536,
"configured_sapphire_price": 128,
"daily_rotation_rules_preserved": true,
"mailbox_delivery": true,
"recreates_initial_reward": false,
"provenance": "sanctuary:shop_purchase",
"replacement_logged": true
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"registry_ids_preserved_or_migrated": true,
"client_server_update_required": true,
"itsalive_data_version": 4,
"itsalive_network_protocol": 4,
"ouch_data_version": 1,
"ouch_network_protocol": 1,
"masterkey_data_version": 1,
"masterkey_network_protocol": 2,
"iliketomoveit_data_version": 4,
"iliketomoveit_network_protocol": 5,
"onlyfun_data_version": 2,
"onlyfun_network_protocol": 3,
"sanctuary_data_version": 13,
"sanctuary_notch_state_data_version": 2,
"sanctuary_source_network_protocol": 22,
"sanctuary_target_network_protocol": 23,
"sanctuary_network_protocol_changed": true
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true
}
}
@@ -0,0 +1,87 @@
{
"schema_version": 1,
"status": "released",
"source": {
"pack_version": "26.2.0-alpha.224",
"modules": {
"anotherworld": "0.0.0-alpha.55",
"itsalive": "0.0.0-alpha.37",
"iliketomoveit": "0.0.0-alpha.17",
"onlyfun": "0.0.0-alpha.26",
"sanctuary": "0.0.0-alpha.128"
}
},
"target": {
"pack_version": "26.2.0-alpha.225",
"modules": {
"anotherworld": "0.0.0-alpha.56",
"itsalive": "0.0.0-alpha.38",
"iliketomoveit": "0.0.0-alpha.18",
"onlyfun": "0.0.0-alpha.27",
"sanctuary": "0.0.0-alpha.129"
}
},
"fixed_issues": [105, 116, 123, 124],
"safe_vault_raid": {
"owner": "anotherworld",
"attacker_requires_registered_vault": true,
"validated_when_drilling_starts": true,
"revalidated_during_drilling": true,
"revalidated_before_balance_transfer": true
},
"temporary_companions": {
"owner": "sanctuary",
"public_policy_api": "SanctuaryEntityPolicy",
"vanilla_loot_suppressed": true,
"experience_suppressed": true,
"quest_board_progress_suppressed": true,
"community_request_progress_suppressed": true,
"onlyfun_lootboxes_suppressed": true
},
"canaplia_variants": {
"owner": "itsalive",
"historical_seed_ids_preserved": 16,
"placement_preserves_variant": true,
"natural_growth_preserves_variant": true,
"bonemeal_preserves_variant": true,
"upper_crop_preserves_variant": true,
"block_state_persistence_preserves_variant": true,
"harvest_and_break_drops_preserve_variant": true,
"mature_neighbors_preserve_variant": true,
"tender_plus_olive_can_produce_gold": true
},
"magic_carpet_texture": {
"owner": "iliketomoveit",
"canonical_item": "iliketomoveit:magic_carpet",
"legacy_item": "portals:notch_totem",
"texture": "iliketomoveit:item/magic_carpet",
"historical_totem_art_restored": true,
"purple_wool_reference_removed": true,
"platform_behavior_changed": false
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"registry_ids_changed": false,
"persistent_data_formats_changed": false,
"network_protocols_changed": false,
"client_server_update_required": true,
"anotherworld_data_version": 5,
"anotherworld_network_protocol": 2,
"itsalive_data_version": 4,
"itsalive_network_protocol": 4,
"iliketomoveit_data_version": 4,
"iliketomoveit_network_protocol": 5,
"onlyfun_data_version": 2,
"onlyfun_network_protocol": 3,
"sanctuary_data_version": 13,
"sanctuary_network_protocol": 23
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true
}
}
@@ -0,0 +1,49 @@
{
"schema_version": 1,
"status": "released",
"source": {
"pack_version": "26.2.0-alpha.225",
"modules": {
"sanctuary": "0.0.0-alpha.129"
}
},
"target": {
"pack_version": "26.2.0-alpha.226",
"modules": {
"sanctuary": "0.0.0-alpha.130"
}
},
"white_cape": {
"owner": "sanctuary",
"item_id": "sanctuary:white_cape",
"crafting_material": "minecraft:white_wool",
"crafting_material_count": 6,
"crafting_pattern": ["WW", "WW", "WW"],
"banner_decoration_recipe": "cape_plus_banner",
"accepted_banners": "#minecraft:banners",
"base_color_preserved": true,
"maximum_pattern_layers": 6,
"inventory_pattern_rendering": true,
"equipped_pattern_rendering": true,
"vanilla_cape_physics": true,
"included_in_onlyfun_lootbox": false
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"registry_ids_added": ["sanctuary:white_cape"],
"persistent_data_formats_changed": false,
"sanctuary_data_version": 13,
"sanctuary_source_network_protocol": 23,
"sanctuary_target_network_protocol": 24,
"sanctuary_network_protocol_changed": true,
"client_server_update_required": true
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true
}
}
@@ -0,0 +1,60 @@
{
"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"
}
}
}
@@ -0,0 +1,58 @@
{
"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
}
}
@@ -0,0 +1,45 @@
{
"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
}
}
@@ -0,0 +1,44 @@
{
"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
}
}
@@ -0,0 +1,56 @@
{
"schema_version": 1,
"status": "released",
"source": {
"pack_version": "26.2.0-alpha.230",
"modules": {
"sanctuary": "0.0.0-alpha.136",
"redstoner": "0.0.0-alpha.17"
}
},
"target": {
"pack_version": "26.2.0-alpha.231",
"modules": {
"sanctuary": "0.0.0-alpha.137",
"redstoner": "0.0.0-alpha.18"
}
},
"backroom_landmarks": {
"level_count": 5,
"loot_chest_cell_size_chunks": 3,
"exit_bed_source_cell_size_chunks": 10,
"exit_bed_target_cell_size_chunks": 3,
"books_added_to_loot": false,
"new_chunks_only": true
},
"trophy_panel": {
"wall_plate_model": "oak",
"readable_line_count": 4,
"page_title_and_three_podium_rows": true,
"right_click_next_page": true,
"sneak_right_click_previous_page": true
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"persistent_data_formats_changed": false,
"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,
"jei": {
"source": "30.29.0.199",
"target": "30.29.0.201",
"packwiz_and_prism_synchronized": true
}
}
}
@@ -0,0 +1,58 @@
{
"schema_version": 1,
"status": "released",
"source": {
"pack_version": "26.2.0-alpha.231",
"modules": {
"anotherworld": "0.0.0-alpha.57",
"sanctuary": "0.0.0-alpha.137",
"redstoner": "0.0.0-alpha.18"
}
},
"target": {
"pack_version": "26.2.0-alpha.232",
"modules": {
"anotherworld": "0.0.0-alpha.58",
"sanctuary": "0.0.0-alpha.138",
"redstoner": "0.0.0-alpha.19"
}
},
"fixed_issues": [146],
"canon_photo_screenshots": {
"directory": "screenshots",
"viewfinder_crop_only": true,
"film_filter_preserved": true,
"full_crop_resolution": true,
"asynchronous_png_write": true,
"collision_safe_names": true
},
"alpha231_follow_up": {
"migration": "alpha231-follow-up-cape-terminal-trophy.json",
"banner_cape_fixed": true,
"terminal_inventory_key_fixed": true,
"trophy_panel_visible_lines": 8,
"trophy_panel_entries_per_view": 7,
"spawn_egg_kill_ranking": true
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"persistent_data_formats_changed": false,
"network_protocols_changed": true,
"anotherworld_data_version": 5,
"anotherworld_network_protocol": 2,
"sanctuary_data_version": 15,
"sanctuary_network_protocol": 24,
"redstoner_data_version": 4,
"redstoner_source_network_protocol": 7,
"redstoner_target_network_protocol": 8,
"client_server_update_required": true
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true
}
}
@@ -0,0 +1,37 @@
{
"schema_version": 1,
"status": "released",
"released_in": "26.2.0-alpha.232",
"modules": {
"sanctuary": {
"source_module_version": "0.0.0-alpha.137",
"target_module_version": "0.0.0-alpha.138",
"data_version": 15,
"network_protocol": 24
},
"redstoner": {
"source_module_version": "0.0.0-alpha.18",
"target_module_version": "0.0.0-alpha.19",
"data_version": 4,
"source_network_protocol": 7,
"target_network_protocol": 8
}
},
"fixes": {
"banner_cape_flat_shield_uv_faces": true,
"banner_cape_double_sided_culling": true,
"terminal_inventory_key_captured_while_searching": true,
"trophy_panel_visible_lines": 8,
"trophy_panel_entries_per_view": 7,
"trophy_panel_names_then_values": true,
"spawn_egg_maps_to_entity_killed_statistic": true
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"persistent_data_formats_changed": false,
"network_protocols_changed": true,
"client_server_update_required": true
}
}
@@ -0,0 +1,38 @@
{
"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"
]
}
}
@@ -0,0 +1,64 @@
{
"schema_version": 1,
"status": "released",
"released_in": "26.2.0-alpha.233",
"pack": {
"source_version": "26.2.0-alpha.232",
"target_version": "26.2.0-alpha.233"
},
"modules": {
"sanctuary": {
"source_module_version": "0.0.0-alpha.138",
"target_module_version": "0.0.0-alpha.139",
"data_version": 15,
"network_protocol": 24
},
"redstoner": {
"source_module_version": "0.0.0-alpha.19",
"target_module_version": "0.0.0-alpha.20",
"source_data_version": 4,
"target_data_version": 5,
"source_network_protocol": 8,
"target_network_protocol": 9
}
},
"trophy_sign": {
"owner": "redstoner",
"item_id_preserved": "redstoner:trophy_panel",
"standing_block_id": "redstoner:trophy_sign",
"wall_block_id": "redstoner:trophy_wall_sign",
"legacy_block_id": "redstoner:trophy_panel",
"legacy_blocks_migrate_on_load": true,
"vanilla_sign_block_entity": true,
"vanilla_wood_type": "pale_oak",
"save_key": "redstoner:trophy_signs",
"save_data_version": 1,
"maximum_signs": 16384,
"maximum_pages_per_sign": 16,
"editor_opens_on_first_placement": true,
"owner_or_operator_configuration": true,
"owner_or_operator_breaking": true,
"public_right_click_page_cycle": true,
"display_modes": ["names_and_values", "names", "values"],
"server_revalidates_distance_owner_block_and_metrics": true
},
"ranking_api": {
"client_catalog_built_from_synchronized_registries": true,
"catalog_sent_over_network": false,
"cached_player_names_repair_uuid_prefix_placeholders": true,
"uuid_prefixes_never_used_as_display_fallback": true
},
"compatibility": {
"existing_saves_supported": true,
"existing_item_stacks_supported": true,
"new_world_required": false,
"save_reset_required": false,
"monotone_data_migration": true,
"client_server_update_required": true
},
"distribution": {
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true
}
}
@@ -0,0 +1,36 @@
{
"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
}
}
+20 -20
View File
@@ -14,24 +14,24 @@ hash = "41f4f4c88c71d0a23446d5014f7407d55ad477441847099cad24492b032335a0"
[[files]]
file = "mods/ambiance-0.0.0-alpha.22.jar"
hash = "34aa79ab54ba2506b0ffef062662917de8b29f6f3f9b8d7a920a664d01185641"
hash = "c72a87d3ea187e65f3d459d18954bfc557deeb741deea143e610604a3af5985f"
[[files]]
file = "mods/anotherworld-0.0.0-alpha.55.jar"
hash = "2c93c364af7f617a488a8ef5c64d584041eeb24b93e4ea727570c042bd73fe2d"
file = "mods/anotherworld-0.0.0-alpha.58.jar"
hash = "51f0122c222317fd65a642f8568a3d822732a874ecd5e4433592ae96e2a535a8"
[[files]]
file = "mods/events-0.0.0-alpha.0.jar"
hash = "6c94ba89f6daf64d7144e9ea2283a98a609a01a20b7913bd51821034ee8a26fd"
hash = "1c1623d4d40973346d3143657461e916e38142a867e7a6cf0da054ad998dbb0e"
[[files]]
file = "mods/fabric-api.pw.toml"
hash = "df032a6b051d0262cf24945c43e0e6b915e8cc1d62af62fe933932e7f35960c4"
hash = "b8ea5f403a0cdfbfd5ab1704bb61278e8dcf31b525db9c242b22047852d34f85"
metafile = true
[[files]]
file = "mods/iliketomoveit-0.0.0-alpha.16.jar"
hash = "f0f54b9746977b085abb74fea61e8cd230f3dda905cc337866e7950fb2ee9293"
file = "mods/iliketomoveit-0.0.0-alpha.18.jar"
hash = "6a38653f4898e2fe2df6f8935b516073185f7c0d3913164e769ebafcc487b318"
[[files]]
file = "mods/iris.pw.toml"
@@ -39,33 +39,33 @@ hash = "84a3cef87679c0d21dceb4641167dc0368b9a3593698f6a3632485e9b3a8d5da"
metafile = true
[[files]]
file = "mods/itsalive-0.0.0-alpha.36.jar"
hash = "ae2201e91e4ab2eaf09f32ea41c920d460cf3cf426e71bd76be5241b7e81e1a0"
file = "mods/itsalive-0.0.0-alpha.38.jar"
hash = "0e40259acf98cf3d64dfdebe3441ffb3a09aa64de6cf1b2c8dcaa4af7c8d0e82"
[[files]]
file = "mods/jei.pw.toml"
hash = "cffb13bae33cd8df605c62e7fa66089428dc684e0ab973f662a8a710c45c306d"
hash = "80d0fa55c1bbc0659e792b047c2bb64c446c2bf92557d9d74777333a8a68a318"
metafile = true
[[files]]
file = "mods/masterkey-0.0.0-alpha.8.jar"
hash = "60de65566b1434709cdfb4c915d5bc6a7f9019dd48ebeaa04fe474f0ec7fd3b4"
file = "mods/masterkey-0.0.0-alpha.9.jar"
hash = "b42b25f1eabc0b1e7660f0071e8f105d82ceb078b4bd9b7c570df8ce2c59601c"
[[files]]
file = "mods/onlyfun-0.0.0-alpha.25.jar"
hash = "165ffe713b09db64484cceb6c508ac5e53b5d7e69e86ea6658c3e8ea04ee1ccf"
file = "mods/onlyfun-0.0.0-alpha.27.jar"
hash = "4e2c95c54fa3f591d3e1b6f7454211caaa4fb5f7ef31cd5b6aa477d03be18e2e"
[[files]]
file = "mods/ouch-0.0.0-alpha.10.jar"
hash = "3f66a1b0657c40b653ec9e7a7aa92e4edf72475af7e372a49d9254743696f0a5"
file = "mods/ouch-0.0.0-alpha.11.jar"
hash = "8dcec6870fba091adda5df2a2f719e97d0f5dcf86f14c5f99035c7aebaf6c119"
[[files]]
file = "mods/redstoner-0.0.0-alpha.13.jar"
hash = "226c000f5febd841d69420316af2c14dd4ec0ef1c774c21d5926f7120f44b7e6"
file = "mods/redstoner-0.0.0-alpha.20.jar"
hash = "669ca9a357874448c68ca45fdf26d827d5094b1ef1ee07f948a564b33cc1a79c"
[[files]]
file = "mods/sanctuary-0.0.0-alpha.125.jar"
hash = "f0826d9be66f6498bc0455ea8075bfbaefc97c1f6a7b0d21b225003fe71ad958"
file = "mods/sanctuary-0.0.0-alpha.139.jar"
hash = "62d06cfe57badc5295fdcaf1ad9d0eb6d7ba67345bdc5fc8ca6d689817f70c01"
[[files]]
file = "mods/simple-voice-chat.pw.toml"
+4 -4
View File
@@ -1,14 +1,14 @@
name = "Fabric API"
filename = "fabric-api-0.158.0+26.2.jar"
filename = "fabric-api-0.159.0+26.2.jar"
side = "both"
pin = true
[download]
url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/NqwNSxwA/fabric-api-0.158.0%2B26.2.jar"
url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/BgeCGgGZ/fabric-api-0.159.0%2B26.2.jar"
hash-format = "sha512"
hash = "4c2c1ebe74ffd54875a01ff371b53ba3d8674ac98d561f7dae02a96d3d37fbdbc5f5abc6e820f73b6154d6f873ddd05a442b0998ed2d456863dc0ad972e040a6"
hash = "4d5d9c3daafe19be187f638f2d6de3db26420b8720ef5a266cfa81f79d9019cab8c86e6d47478bbb0b0ee0b8f769204b72c843caa2e4aabd94adc41f3dafb518"
[update]
[update.modrinth]
mod-id = "P7dR8mSH"
version = "NqwNSxwA"
version = "BgeCGgGZ"
+4 -4
View File
@@ -1,14 +1,14 @@
name = "Just Enough Items (JEI)"
filename = "jei-26.2-fabric-30.29.0.198.jar"
filename = "jei-26.2-fabric-30.29.0.201.jar"
side = "both"
pin = true
[download]
url = "https://cdn.modrinth.com/data/u6dRKJwZ/versions/67Dvn8sL/jei-26.2-fabric-30.29.0.198.jar"
url = "https://cdn.modrinth.com/data/u6dRKJwZ/versions/d3HeviSL/jei-26.2-fabric-30.29.0.201.jar"
hash-format = "sha512"
hash = "06131c9528549840e07ddc9634e23454cd454380d7749a23d818f0efe0dacc02874e967ceb74a24ee82efb13e0fe2df4026f988a68f54f890586967e91dcaa5b"
hash = "2279cb0ec4e41d9eebad80c2ecac76d19c8bc8e5aac69ba13f35b77494625c70d66edc74d59959116012bfb2f464b55806dd69fbf6387f25679b1553953e329a"
[update]
[update.modrinth]
mod-id = "u6dRKJwZ"
version = "67Dvn8sL"
version = "d3HeviSL"
+2 -2
View File
@@ -1,12 +1,12 @@
name = "Sanctuary 26.2"
author = "KOKA99CAB"
version = "26.2.0-alpha.223"
version = "26.2.0-alpha.233"
pack-format = "packwiz:1.1.0"
[index]
file = "index.toml"
hash-format = "sha256"
hash = "9e06da88d562c7a2ed5fbcd56a06779a37715d4f9cf26479ee1a664758ada599"
hash = "c666b031c2ac27a8f01cc4d12d6e471ea7471e9e5e7ce02567ee8a6649f9142a"
[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.223",
"versionId": "26.2.0-alpha.233",
"files": [
{
"path": "mods/fabric-api-0.158.0+26.2.jar",
"path": "mods/fabric-api-0.159.0+26.2.jar",
"hashes": {
"sha1": "7d0fb560579d28b933bdbc36b47449efe78d3bda",
"sha512": "4c2c1ebe74ffd54875a01ff371b53ba3d8674ac98d561f7dae02a96d3d37fbdbc5f5abc6e820f73b6154d6f873ddd05a442b0998ed2d456863dc0ad972e040a6"
"sha1": "5307b87f8336f27e8f963263ac514f4bc6175d90",
"sha512": "4d5d9c3daafe19be187f638f2d6de3db26420b8720ef5a266cfa81f79d9019cab8c86e6d47478bbb0b0ee0b8f769204b72c843caa2e4aabd94adc41f3dafb518"
},
"env": {
"client": "required",
"server": "required"
},
"downloads": [
"https://cdn.modrinth.com/data/P7dR8mSH/versions/NqwNSxwA/fabric-api-0.158.0%2B26.2.jar"
"https://cdn.modrinth.com/data/P7dR8mSH/versions/BgeCGgGZ/fabric-api-0.159.0%2B26.2.jar"
],
"fileSize": 2535765
"fileSize": 2535793
},
{
"path": "mods/jei-26.2-fabric-30.29.0.198.jar",
"path": "mods/jei-26.2-fabric-30.29.0.201.jar",
"hashes": {
"sha1": "652e964c71a699fdfdd96cda265ea703b13c6ef4",
"sha512": "06131c9528549840e07ddc9634e23454cd454380d7749a23d818f0efe0dacc02874e967ceb74a24ee82efb13e0fe2df4026f988a68f54f890586967e91dcaa5b"
"sha1": "e82447aff07e87a372cf817bc9c28b0d77b6f55c",
"sha512": "2279cb0ec4e41d9eebad80c2ecac76d19c8bc8e5aac69ba13f35b77494625c70d66edc74d59959116012bfb2f464b55806dd69fbf6387f25679b1553953e329a"
},
"env": {
"client": "required",
"server": "required"
},
"downloads": [
"https://cdn.modrinth.com/data/u6dRKJwZ/versions/67Dvn8sL/jei-26.2-fabric-30.29.0.198.jar"
"https://cdn.modrinth.com/data/u6dRKJwZ/versions/d3HeviSL/jei-26.2-fabric-30.29.0.201.jar"
],
"fileSize": 1839279
"fileSize": 1860103
},
{
"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.223",
"pack_version": "26.2.0-alpha.233",
"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.223.mrpack",
"primary_file": "sanctuary-26.2.0-alpha.233.mrpack",
"shared_client_server_pack": true,
"update_owner": "packwiz-installer",
"check_on_launch": true,
@@ -4,6 +4,7 @@ import fr.koka99cab.sanctuary26.redstoner.api.RedStonerApi;
import fr.koka99cab.sanctuary26.redstoner.network.RedStonerNetworking;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerMenus;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophySignService;
import fr.koka99cab.sanctuary26.sanctuary.api.server.SanctuaryServerIdentity;
import net.fabricmc.api.ModInitializer;
import net.minecraft.resources.Identifier;
@@ -13,7 +14,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 = 3;
public static final int CURRENT_DATA_VERSION = 5;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public RedStonerMod() {
@@ -27,9 +28,10 @@ public final class RedStonerMod implements ModInitializer {
public void onInitialize() {
RedStonerApi.registerServerIdentityProvider(SanctuaryServerIdentity::get);
RedStonerRegistries.initialize();
TrophySignService.initialize();
RedStonerMenus.initialize();
RedStonerNetworking.initialize();
LOGGER.info("[{}] Computers, Drawer, Particuleur and Storage Terminal ready (data v{}, network v{}).",
LOGGER.info("[{}] Computers, Drawer, Particuleur, Storage Terminal and Trophy Panel ready (data v{}, network v{}).",
DISPLAY_NAME, CURRENT_DATA_VERSION, RedStonerNetworking.PROTOCOL_VERSION);
}
}
@@ -0,0 +1,176 @@
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;
}
}
@@ -0,0 +1,303 @@
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.redstoner.trophy.TrophySignService;
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 String CLIENT_VALUES_VIEW_TAG = "ValuesView";
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 boolean valuesView;
private long lastRefreshTick = Long.MIN_VALUE;
private boolean migrationAttempted;
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;
if (!panel.migrationAttempted) {
panel.migrationAttempted = true;
if (TrophySignService.migrateLegacy(serverLevel, pos, state.getValue(TrophyPanelBlock.FACING),
panel.owner, panel.ownerName, panel.pages.stream().map(Page::metricId).toList(), panel.currentPage)) {
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())));
valuesView = false;
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;
TrophyPanelRules.DisplayPage displayPage = TrophyPanelRules.displayPageByOffset(
currentPage, valuesView, pages.size(), offset);
currentPage = displayPage.metricPage();
valuesView = displayPage.valuesView();
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 TrophyPanelRules.displayPageNumber(currentPage, valuesView, pages.size());
}
public int pageCount() {
return TrophyPanelRules.displayPageCount(pages.size());
}
public boolean valuesView() {
return valuesView;
}
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);
valuesView = input.getBooleanOr(CLIENT_VALUES_VIEW_TAG, false);
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);
tag.putBoolean(CLIENT_VALUES_VIEW_TAG, valuesView);
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);
}
}
}
@@ -4,9 +4,12 @@ import fr.koka99cab.sanctuary26.redstoner.RedStonerMod;
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.gui.TrophySignEditorScreen;
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.network.OpenTrophySignEditorPayload;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerMenus;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import net.fabricmc.api.ClientModInitializer;
@@ -19,6 +22,8 @@ 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))));
@@ -30,6 +35,9 @@ public final class RedStonerClient implements ClientModInitializer {
context.client().gui.setScreen(new PersonalComputerScreen(payload));
}
}));
ClientPlayNetworking.registerGlobalReceiver(OpenTrophySignEditorPayload.TYPE, (payload, context) ->
context.client().execute(() -> context.client().gui.setScreen(
new TrophySignEditorScreen(context.client().gui.screen(), payload))));
RedStonerMod.LOGGER.info("[{}] Client renderers and laboratory screens ready.", RedStonerMod.DISPLAY_NAME);
}
@@ -1,13 +1,17 @@
package fr.koka99cab.sanctuary26.redstoner.client.gui;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalKeyRules;
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;
@@ -28,8 +32,10 @@ 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);
@@ -40,13 +46,16 @@ public final class StorageTerminalScreen extends AbstractContainerScreen<Storage
protected void init() {
super.init();
titleLabelY = 6;
searchBox = addRenderableWidget(new EditBox(font, leftPos + 8, topPos + 2, 126, 14,
searchBox = addRenderableWidget(new EditBox(font, leftPos + 8, topPos + 2, 96, 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);
setInitialFocus(searchBox);
sortButton = addRenderableWidget(Button.builder(sortButtonLabel(), button -> requestSort())
.bounds(leftPos + 108, topPos + 2, 34, 14)
.tooltip(Tooltip.create(sortTooltip()))
.build());
}
@Override
@@ -114,6 +123,10 @@ 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())) {
@@ -127,10 +140,9 @@ public final class StorageTerminalScreen extends AbstractContainerScreen<Storage
@Override
public boolean keyPressed(KeyEvent event) {
if (searchBox != null && searchBox.isFocused() && event.key() != 256) {
searchBox.keyPressed(event);
return true;
}
if (StorageTerminalKeyRules.capturesInventoryKey(
searchBox != null && searchBox.isFocused(), minecraft != null
&& minecraft.options.keyInventory.matches(event))) return true;
return super.keyPressed(event);
}
@@ -148,6 +160,28 @@ 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;
@@ -173,6 +207,11 @@ 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;
@@ -0,0 +1,265 @@
package fr.koka99cab.sanctuary26.redstoner.client.gui;
import fr.koka99cab.sanctuary26.redstoner.network.OpenTrophySignEditorPayload;
import fr.koka99cab.sanctuary26.redstoner.network.SaveTrophySignPayload;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyDisplayMode;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyPanelRules;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingMetric;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingUnit;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.SanctuaryRankingApi;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
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.screens.Screen;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
/** White, bounded editor inspired by the community quest-sign screen. */
public final class TrophySignEditorScreen extends Screen {
private static final int VISIBLE_ROWS = 5;
private static final int PANEL_WIDTH = 328;
private static final int PANEL_HEIGHT = 250;
private static final int COLUMN_WIDTH = 148;
private static final int GAP = 8;
private static final int BACKDROP = 0xD0F1F0EC;
private static final int BORDER = 0xFFFFFFFF;
private static final int TITLE = 0xFF343434;
private static final int MUTED = 0xFF6A6A6A;
private final Screen parent;
private final OpenTrophySignEditorPayload payload;
private final List<Identifier> pages;
private final List<MetricOption> catalog = new ArrayList<>();
private final List<MetricOption> filtered = new ArrayList<>();
private final Button[] resultButtons = new Button[VISIBLE_ROWS];
private final Button[] pageButtons = new Button[VISIBLE_ROWS];
private EditBox search;
private Button previousResults;
private Button nextResults;
private Button previousPages;
private Button nextPages;
private Button moveUp;
private Button moveDown;
private Button remove;
private Button displayMode;
private Button save;
private TrophyDisplayMode mode;
private int catalogOffset;
private int pageOffset;
private int selectedPage = -1;
public TrophySignEditorScreen(Screen parent, OpenTrophySignEditorPayload payload) {
super(Component.translatable("screen.redstoner.trophy_sign.title"));
this.parent = parent;
this.payload = payload;
this.pages = new ArrayList<>(payload.pages());
this.mode = TrophyDisplayMode.byKey(payload.display());
if (!pages.isEmpty()) selectedPage = 0;
}
@Override
protected void init() {
int x = panelX() + 12;
int y = panelY();
buildCatalog();
search = addRenderableWidget(new EditBox(font, x, y + 38, COLUMN_WIDTH, 20,
Component.translatable("screen.redstoner.trophy_sign.search")));
search.setMaxLength(64);
search.setHint(Component.translatable("screen.redstoner.trophy_sign.search"));
search.setResponder(value -> filterCatalog());
for (int slot = 0; slot < VISIBLE_ROWS; slot++) {
int resultSlot = slot;
resultButtons[slot] = addRenderableWidget(Button.builder(Component.empty(),
button -> addResult(resultSlot)).bounds(x, y + 62 + slot * 22, COLUMN_WIDTH, 20).build());
int pageSlot = slot;
pageButtons[slot] = addRenderableWidget(Button.builder(Component.empty(),
button -> selectPage(pageSlot)).bounds(x + COLUMN_WIDTH + GAP, y + 38 + slot * 22,
COLUMN_WIDTH, 20).build());
}
previousResults = addRenderableWidget(Button.builder(Component.literal(""), button -> moveCatalog(-VISIBLE_ROWS))
.bounds(x, y + 174, 72, 20).build());
nextResults = addRenderableWidget(Button.builder(Component.literal(""), button -> moveCatalog(VISIBLE_ROWS))
.bounds(x + 76, y + 174, 72, 20).build());
previousPages = addRenderableWidget(Button.builder(Component.literal(""), button -> movePageWindow(-VISIBLE_ROWS))
.bounds(x + COLUMN_WIDTH + GAP, y + 174, 72, 20).build());
nextPages = addRenderableWidget(Button.builder(Component.literal(""), button -> movePageWindow(VISIBLE_ROWS))
.bounds(x + COLUMN_WIDTH + GAP + 76, y + 174, 72, 20).build());
moveUp = addRenderableWidget(Button.builder(Component.literal(""), button -> moveSelected(-1))
.bounds(x + COLUMN_WIDTH + GAP, y + 150, 46, 20).build());
moveDown = addRenderableWidget(Button.builder(Component.literal(""), button -> moveSelected(1))
.bounds(x + COLUMN_WIDTH + GAP + 50, y + 150, 46, 20).build());
remove = addRenderableWidget(Button.builder(Component.translatable("screen.redstoner.trophy_sign.remove"),
button -> removeSelected()).bounds(x + COLUMN_WIDTH + GAP + 100, y + 150, 48, 20).build());
displayMode = addRenderableWidget(Button.builder(displayLabel(), button -> {
mode = mode.next();
button.setMessage(displayLabel());
}).bounds(x, y + 198, COLUMN_WIDTH * 2 + GAP, 20).build());
save = addRenderableWidget(Button.builder(Component.translatable("gui.done"), button -> submit())
.bounds(x, y + 224, COLUMN_WIDTH, 20).build());
addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
.bounds(x + COLUMN_WIDTH + GAP, y + 224, COLUMN_WIDTH, 20).build());
filterCatalog();
refreshWidgets();
setInitialFocus(search);
}
@Override
public void tick() {
refreshWidgets();
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta) {
int x = panelX();
int y = panelY();
graphics.fill(x, y, x + PANEL_WIDTH, y + PANEL_HEIGHT, BACKDROP);
graphics.outline(x, y, PANEL_WIDTH, PANEL_HEIGHT, BORDER);
graphics.centeredText(font, title, width / 2, y + 10, TITLE);
graphics.text(font, Component.translatable("screen.redstoner.trophy_sign.available"),
x + 12, y + 27, MUTED);
graphics.text(font, Component.translatable("screen.redstoner.trophy_sign.pages",
pages.size(), TrophyPanelRules.MAX_PAGES), x + 12 + COLUMN_WIDTH + GAP, y + 27, MUTED);
super.extractRenderState(graphics, mouseX, mouseY, tickDelta);
}
private void buildCatalog() {
catalog.clear();
for (RankingMetric metric : SanctuaryRankingApi.configurableMetrics()) {
Component label = SanctuaryRankingApi.displayName(metric);
catalog.add(new MetricOption(metric, label, (label.getString() + " " + metric.id())
.toLowerCase(Locale.ROOT)));
}
catalog.sort(Comparator.comparing(option -> option.label().getString(), String.CASE_INSENSITIVE_ORDER));
}
private void filterCatalog() {
if (search == null) return;
String query = search.getValue().strip().toLowerCase(Locale.ROOT);
filtered.clear();
for (MetricOption option : catalog) if (query.isEmpty() || option.searchText().contains(query)) filtered.add(option);
catalogOffset = 0;
refreshWidgets();
}
private void addResult(int slot) {
int index = catalogOffset + slot;
if (index < 0 || index >= filtered.size()) return;
Identifier id = filtered.get(index).metric().id();
int existing = pages.indexOf(id);
if (existing >= 0) {
selectedPage = existing;
ensureSelectedVisible();
} else if (pages.size() < TrophyPanelRules.MAX_PAGES) {
pages.add(id);
selectedPage = pages.size() - 1;
ensureSelectedVisible();
}
refreshWidgets();
}
private void selectPage(int slot) {
int index = pageOffset + slot;
if (index >= 0 && index < pages.size()) selectedPage = index;
refreshWidgets();
}
private void moveSelected(int offset) {
int target = selectedPage + offset;
if (selectedPage < 0 || target < 0 || target >= pages.size()) return;
Identifier page = pages.remove(selectedPage);
pages.add(target, page);
selectedPage = target;
ensureSelectedVisible();
refreshWidgets();
}
private void removeSelected() {
if (selectedPage < 0 || selectedPage >= pages.size()) return;
pages.remove(selectedPage);
selectedPage = pages.isEmpty() ? -1 : Math.min(selectedPage, pages.size() - 1);
ensureSelectedVisible();
refreshWidgets();
}
private void moveCatalog(int amount) {
int maximum = Math.max(0, filtered.size() - VISIBLE_ROWS);
catalogOffset = Math.clamp(catalogOffset + amount, 0, maximum);
refreshWidgets();
}
private void movePageWindow(int amount) {
int maximum = Math.max(0, pages.size() - VISIBLE_ROWS);
pageOffset = Math.clamp(pageOffset + amount, 0, maximum);
refreshWidgets();
}
private void ensureSelectedVisible() {
if (selectedPage < pageOffset) pageOffset = selectedPage;
if (selectedPage >= pageOffset + VISIBLE_ROWS) pageOffset = selectedPage - VISIBLE_ROWS + 1;
pageOffset = Math.max(0, pageOffset);
}
private void refreshWidgets() {
if (save == null) return;
for (int slot = 0; slot < VISIBLE_ROWS; slot++) {
int resultIndex = catalogOffset + slot;
Button result = resultButtons[slot];
result.visible = resultIndex < filtered.size();
if (result.visible) {
MetricOption option = filtered.get(resultIndex);
result.setMessage(Component.literal(pages.contains(option.metric().id()) ? "" : "+ ")
.append(option.label()));
result.active = pages.contains(option.metric().id()) || pages.size() < TrophyPanelRules.MAX_PAGES;
}
int pageIndex = pageOffset + slot;
Button page = pageButtons[slot];
page.visible = pageIndex < pages.size();
if (page.visible) page.setMessage(Component.literal(pageIndex == selectedPage ? "" : "")
.append(Component.literal((pageIndex + 1) + ". ")).append(metricLabel(pages.get(pageIndex))));
}
previousResults.active = catalogOffset > 0;
nextResults.active = catalogOffset + VISIBLE_ROWS < filtered.size();
previousPages.active = pageOffset > 0;
nextPages.active = pageOffset + VISIBLE_ROWS < pages.size();
moveUp.active = selectedPage > 0;
moveDown.active = selectedPage >= 0 && selectedPage + 1 < pages.size();
remove.active = selectedPage >= 0;
save.active = !pages.isEmpty() && ClientPlayNetworking.canSend(SaveTrophySignPayload.TYPE);
}
private Component metricLabel(Identifier id) {
RankingMetric metric = SanctuaryRankingApi.metric(id).orElseGet(() ->
new RankingMetric(id, "metric.sanctuary.ranking.unknown", RankingUnit.COUNT));
return SanctuaryRankingApi.displayName(metric);
}
private Component displayLabel() {
return Component.translatable("screen.redstoner.trophy_sign.display",
Component.translatable("screen.redstoner.trophy_sign.display." + mode.key()));
}
private void submit() {
if (pages.isEmpty() || !ClientPlayNetworking.canSend(SaveTrophySignPayload.TYPE)) return;
ClientPlayNetworking.send(new SaveTrophySignPayload(payload.pos(), pages, mode.key()));
onClose();
}
private int panelX() { return (width - PANEL_WIDTH) / 2; }
private int panelY() { return Math.max(2, (height - PANEL_HEIGHT) / 2); }
@Override public void onClose() { if (minecraft != null) minecraft.gui.setScreen(parent); }
@Override public boolean isPauseScreen() { return false; }
private record MetricOption(RankingMetric metric, Component label, String searchText) {}
}
@@ -0,0 +1,94 @@
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.redstoner.trophy.TrophyPanelRules;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingMetric;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingUnit;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.SanctuaryRankingApi;
import java.util.Arrays;
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.86F, 0.75F, 0.64F, 0.53F, 0.42F, 0.31F, 0.20F, 0.09F};
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);
Arrays.fill(state.lines, Component.empty());
ItemStack icon = panel.currentPage().icon();
TrophyPanelBlockEntity.ClientSnapshot snapshot = panel.visibleSnapshot();
RankingUnit unit = snapshot.unit();
Component metric = SanctuaryRankingApi.displayName(
new RankingMetric(snapshot.metricId(), snapshot.translationKey(), unit), icon);
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");
} else {
for (int index = 0; index < TrophyPanelRules.ENTRIES_PER_VIEW; index++) {
if (index >= snapshot.podium().size()) {
break;
}
TrophyPanelBlockEntity.ClientEntry entry = snapshot.podium().get(index);
state.lines[index + 1] = panel.valuesView()
? Component.translatable("block.redstoner.trophy_panel.value",
entry.rank(), unit.format(entry.value()))
: Component.translatable("block.redstoner.trophy_panel.player",
entry.rank(), entry.name());
}
}
}
@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();
}
}
@@ -0,0 +1,16 @@
package fr.koka99cab.sanctuary26.redstoner.client.renderer.state;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyPanelRules;
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 = TrophyPanelRules.LINE_COUNT;
public final Component[] lines = new Component[LINE_COUNT];
public Direction facing = Direction.NORTH;
public TrophyPanelRenderState() {
java.util.Arrays.fill(lines, Component.empty());
}
}
@@ -0,0 +1,51 @@
package fr.koka99cab.sanctuary26.redstoner.network;
import fr.koka99cab.sanctuary26.redstoner.RedStonerMod;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyDisplayMode;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyPanelRules;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Opens the white trophy-sign editor with only its small current configuration. */
public record OpenTrophySignEditorPayload(BlockPos pos, List<Identifier> pages, String display)
implements CustomPacketPayload {
public static final Type<OpenTrophySignEditorPayload> TYPE =
new Type<>(RedStonerMod.id("s2c/open_trophy_sign_editor"));
public static final StreamCodec<RegistryFriendlyByteBuf, OpenTrophySignEditorPayload> STREAM_CODEC =
StreamCodec.of(OpenTrophySignEditorPayload::write, OpenTrophySignEditorPayload::read);
public OpenTrophySignEditorPayload {
pos = pos == null ? BlockPos.ZERO : pos.immutable();
pages = TrophyPanelRules.sanitizeMetricIds(pages);
display = TrophyDisplayMode.byKey(display).key();
}
private static OpenTrophySignEditorPayload read(RegistryFriendlyByteBuf buffer) {
BlockPos pos = buffer.readBlockPos();
int count = boundedCount(buffer.readVarInt());
List<Identifier> pages = new ArrayList<>(count);
for (int index = 0; index < count; index++) pages.add(Identifier.parse(buffer.readUtf(256)));
return new OpenTrophySignEditorPayload(pos, pages, buffer.readUtf(24));
}
private static void write(RegistryFriendlyByteBuf buffer, OpenTrophySignEditorPayload payload) {
buffer.writeBlockPos(payload.pos);
buffer.writeVarInt(payload.pages.size());
for (Identifier page : payload.pages) buffer.writeUtf(page.toString(), 256);
buffer.writeUtf(payload.display, 24);
}
private static int boundedCount(int count) {
if (count < 0 || count > TrophyPanelRules.MAX_PAGES) {
throw new IllegalArgumentException("Invalid trophy-sign page count: " + count);
}
return count;
}
@Override public Type<? extends CustomPacketPayload> type() { return TYPE; }
}
@@ -4,6 +4,7 @@ import fr.koka99cab.sanctuary26.redstoner.RedStonerMod;
import fr.koka99cab.sanctuary26.redstoner.block.entity.ComputerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.PersonalComputerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophySignService;
import fr.koka99cab.sanctuary26.sanctuary.api.atlas.SanctuaryAtlasLandmarks;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
@@ -12,7 +13,7 @@ import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
public final class RedStonerNetworking {
public static final int PROTOCOL_VERSION = 7;
public static final int PROTOCOL_VERSION = 9;
private static boolean initialized;
private RedStonerNetworking() {
@@ -25,6 +26,8 @@ public final class RedStonerNetworking {
initialized = true;
PayloadTypeRegistry.clientboundPlay().register(OpenComputerPayload.TYPE, OpenComputerPayload.STREAM_CODEC);
PayloadTypeRegistry.clientboundPlay().register(OpenPersonalComputerPayload.TYPE, OpenPersonalComputerPayload.STREAM_CODEC);
PayloadTypeRegistry.clientboundPlay().register(OpenTrophySignEditorPayload.TYPE,
OpenTrophySignEditorPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(SaveComputerProgramPayload.TYPE, SaveComputerProgramPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(PlaceAtlasLandmarkPayload.TYPE, PlaceAtlasLandmarkPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(RequestPersonalComputerSnapshotPayload.TYPE,
@@ -33,6 +36,8 @@ public final class RedStonerNetworking {
AcknowledgePersonalComputerIntroPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(ShutdownPersonalComputerPayload.TYPE,
ShutdownPersonalComputerPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(SaveTrophySignPayload.TYPE,
SaveTrophySignPayload.STREAM_CODEC);
ServerPlayNetworking.registerGlobalReceiver(SaveComputerProgramPayload.TYPE, (payload, context) ->
context.server().execute(() -> saveProgram(context.player(), payload)));
ServerPlayNetworking.registerGlobalReceiver(PlaceAtlasLandmarkPayload.TYPE, (payload, context) ->
@@ -43,6 +48,8 @@ public final class RedStonerNetworking {
context.server().execute(() -> acknowledgePersonalComputerIntro(context.player(), payload)));
ServerPlayNetworking.registerGlobalReceiver(ShutdownPersonalComputerPayload.TYPE, (payload, context) ->
context.server().execute(() -> shutdownPersonalComputer(context.player(), payload)));
ServerPlayNetworking.registerGlobalReceiver(SaveTrophySignPayload.TYPE, (payload, context) ->
context.server().execute(() -> TrophySignService.save(context.player(), payload)));
RedStonerMod.LOGGER.debug("[{}] Networking contract v{} registered.", RedStonerMod.DISPLAY_NAME, PROTOCOL_VERSION);
}
@@ -0,0 +1,51 @@
package fr.koka99cab.sanctuary26.redstoner.network;
import fr.koka99cab.sanctuary26.redstoner.RedStonerMod;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyDisplayMode;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyPanelRules;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Bounded editor submission; ownership, distance, block and metrics are all revalidated server-side. */
public record SaveTrophySignPayload(BlockPos pos, List<Identifier> pages, String display)
implements CustomPacketPayload {
public static final Type<SaveTrophySignPayload> TYPE =
new Type<>(RedStonerMod.id("c2s/save_trophy_sign"));
public static final StreamCodec<RegistryFriendlyByteBuf, SaveTrophySignPayload> STREAM_CODEC =
StreamCodec.of(SaveTrophySignPayload::write, SaveTrophySignPayload::read);
public SaveTrophySignPayload {
pos = pos == null ? BlockPos.ZERO : pos.immutable();
pages = TrophyPanelRules.sanitizeMetricIds(pages);
display = TrophyDisplayMode.byKey(display).key();
}
private static SaveTrophySignPayload read(RegistryFriendlyByteBuf buffer) {
BlockPos pos = buffer.readBlockPos();
int count = boundedCount(buffer.readVarInt());
List<Identifier> pages = new ArrayList<>(count);
for (int index = 0; index < count; index++) pages.add(Identifier.parse(buffer.readUtf(256)));
return new SaveTrophySignPayload(pos, pages, buffer.readUtf(24));
}
private static void write(RegistryFriendlyByteBuf buffer, SaveTrophySignPayload payload) {
buffer.writeBlockPos(payload.pos);
buffer.writeVarInt(payload.pages.size());
for (Identifier page : payload.pages) buffer.writeUtf(page.toString(), 256);
buffer.writeUtf(payload.display, 24);
}
private static int boundedCount(int count) {
if (count < 0 || count > TrophyPanelRules.MAX_PAGES) {
throw new IllegalArgumentException("Invalid trophy-sign page count: " + count);
}
return count;
}
@Override public Type<? extends CustomPacketPayload> type() { return TYPE; }
}
@@ -6,12 +6,16 @@ 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 fr.koka99cab.sanctuary26.redstoner.trophy.TrophySignItem;
import java.util.function.Function;
import net.fabricmc.fabric.api.event.player.AttackBlockCallback;
import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityType;
import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
@@ -26,10 +30,15 @@ import net.minecraft.world.item.CreativeModeTab;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.SoundType;
import net.minecraft.world.level.block.StandingSignBlock;
import net.minecraft.world.level.block.WallSignBlock;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.entity.BlockEntityTypes;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.WoodType;
import net.minecraft.world.level.material.MapColor;
public final class RedStonerRegistries {
@@ -71,6 +80,24 @@ 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 StandingSignBlock TROPHY_SIGN = registerBlock(
RedStonerMod.id("trophy_sign"),
properties -> new StandingSignBlock(WoodType.PALE_OAK, properties),
BlockBehaviour.Properties.ofFullCopy(Blocks.PALE_OAK_SIGN).mapColor(MapColor.SNOW)
.setId(blockKey(RedStonerMod.id("trophy_sign")))
);
public static final WallSignBlock TROPHY_WALL_SIGN = registerBlock(
RedStonerMod.id("trophy_wall_sign"),
properties -> new WallSignBlock(WoodType.PALE_OAK, properties),
BlockBehaviour.Properties.ofFullCopy(Blocks.PALE_OAK_WALL_SIGN).mapColor(MapColor.SNOW)
.setId(blockKey(RedStonerMod.id("trophy_wall_sign")))
);
public static final ComputerBlock LEGACY_EIGHT_BIT_COMPUTER = registerBlock(
LEGACY_COMPUTER_ID, ComputerBlock::new, computerProperties(LEGACY_COMPUTER_ID, MapColor.COLOR_RED));
@@ -92,6 +119,7 @@ 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 = registerTrophySignItem();
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);
@@ -115,6 +143,8 @@ 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,
@@ -127,6 +157,7 @@ public final class RedStonerRegistries {
output.accept(CONTROLLER_ITEM);
output.accept(PARTICULEUR_ITEM);
output.accept(STORAGE_TERMINAL_ITEM);
output.accept(TROPHY_PANEL_ITEM);
})
.build()
);
@@ -141,8 +172,16 @@ public final class RedStonerRegistries {
return;
}
initialized = true;
FabricBlockEntityType signType = (FabricBlockEntityType) (Object) BlockEntityTypes.SIGN;
signType.addValidBlock(TROPHY_SIGN);
signType.addValidBlock(TROPHY_WALL_SIGN);
registerDrawerAttack();
RedStonerMod.LOGGER.info("[{}] Registered six active blocks and five legacy block aliases.", RedStonerMod.DISPLAY_NAME);
RedStonerMod.LOGGER.info("[{}] Registered the vanilla trophy-sign pair and retained the old panel for migration.",
RedStonerMod.DISPLAY_NAME);
}
public static boolean isTrophySign(Block block) {
return block == TROPHY_SIGN || block == TROPHY_WALL_SIGN;
}
public static boolean isController(BlockState state) {
@@ -187,6 +226,14 @@ public final class RedStonerRegistries {
new BlockItem(block, new Item.Properties().useBlockDescriptionPrefix().setId(key)));
}
private static Item registerTrophySignItem() {
Identifier id = RedStonerMod.id("trophy_panel");
ResourceKey<Item> key = ResourceKey.create(Registries.ITEM, id);
return Registry.register(BuiltInRegistries.ITEM, key,
new TrophySignItem(TROPHY_SIGN, TROPHY_WALL_SIGN,
new Item.Properties().useBlockDescriptionPrefix().setId(key)));
}
private static ResourceKey<Block> blockKey(Identifier id) {
return ResourceKey.create(Registries.BLOCK, id);
}
@@ -0,0 +1,10 @@
package fr.koka99cab.sanctuary26.redstoner.storage;
/** Keyboard routing shared by the terminal screen and its deterministic smoke test. */
public final class StorageTerminalKeyRules {
private StorageTerminalKeyRules() {}
public static boolean capturesInventoryKey(boolean searchFocused, boolean inventoryKey) {
return searchFocused && inventoryKey;
}
}
@@ -27,6 +27,7 @@ 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;
@@ -49,6 +50,7 @@ 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) {
@@ -131,6 +133,9 @@ 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;
@@ -211,7 +216,7 @@ public final class StorageTerminalMenu extends AbstractContainerMenu {
if (serverPlayer == null || terminalPos == null) return;
discovery = StorageTerminalNetwork.discover(serverPlayer, terminalPos);
List<StorageTerminalInventory.Entry> liveEntries =
StorageTerminalNetwork.filteredEntries(discovery, searchQuery);
sortMode.order(StorageTerminalNetwork.filteredEntries(discovery, searchQuery));
layout.update(liveEntries, resetOrder);
int page = discovery.status() == StorageTerminalStatus.READY
@@ -5,12 +5,16 @@ 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}]+");
@@ -38,14 +42,27 @@ public final class StorageTerminalSearch {
}
private static boolean matches(ItemStack stack, String[] terms) {
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());
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));
}
}
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());
}
}
@@ -0,0 +1,59 @@
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();
}
}
@@ -0,0 +1,28 @@
package fr.koka99cab.sanctuary26.redstoner.trophy;
/** Stable, persisted choice for the three ranking rows of a vanilla trophy sign. */
public enum TrophyDisplayMode {
NAMES_AND_VALUES("names_and_values"),
NAMES("names"),
VALUES("values");
private final String key;
TrophyDisplayMode(String key) {
this.key = key;
}
public String key() {
return key;
}
public TrophyDisplayMode next() {
TrophyDisplayMode[] modes = values();
return modes[(ordinal() + 1) % modes.length];
}
public static TrophyDisplayMode byKey(String key) {
for (TrophyDisplayMode mode : values()) if (mode.key.equals(key)) return mode;
return NAMES_AND_VALUES;
}
}
@@ -0,0 +1,74 @@
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;
public static final int SIGN_LINE_COUNT = 4;
public static final int SIGN_ENTRIES_PER_PAGE = SIGN_LINE_COUNT - 1;
/** Legacy plate dimensions kept only while old block entities migrate. */
public static final int LINE_COUNT = 8;
public static final int ENTRIES_PER_VIEW = LINE_COUNT - 1;
private static final int VIEWS_PER_METRIC = 2;
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 DisplayPage displayPageByOffset(int metricPage, boolean valuesView,
int metricCount, int offset) {
int safeMetricCount = Math.max(1, metricCount);
int safeMetricPage = Math.clamp(metricPage, 0, safeMetricCount - 1);
int currentDisplayPage = safeMetricPage * VIEWS_PER_METRIC + (valuesView ? 1 : 0);
int displayPage = Math.floorMod(currentDisplayPage + offset,
safeMetricCount * VIEWS_PER_METRIC);
return new DisplayPage(displayPage / VIEWS_PER_METRIC,
displayPage % VIEWS_PER_METRIC == 1);
}
public static int displayPageNumber(int metricPage, boolean valuesView, int metricCount) {
int safeMetricCount = Math.max(1, metricCount);
return Math.clamp(metricPage, 0, safeMetricCount - 1) * VIEWS_PER_METRIC
+ (valuesView ? 2 : 1);
}
public static int displayPageCount(int metricCount) {
return Math.max(1, metricCount) * VIEWS_PER_METRIC;
}
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())));
}
public static List<Identifier> sanitizeMetricIds(List<Identifier> requested) {
LinkedHashSet<Identifier> unique = new LinkedHashSet<>();
if (requested != null) {
requested.stream().filter(java.util.Objects::nonNull).limit(MAX_PAGES).forEach(unique::add);
}
return List.copyOf(unique);
}
public record DisplayPage(int metricPage, boolean valuesView) {}
}
@@ -0,0 +1,180 @@
package fr.koka99cab.sanctuary26.redstoner.trophy;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka99cab.sanctuary26.redstoner.RedStonerMod;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import net.minecraft.core.BlockPos;
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;
/** Versioned, server-owned configurations for the vanilla block entities used by trophy signs. */
public final class TrophySignData extends SavedData {
public static final int DATA_VERSION = 1;
public static final String SAVE_KEY = "trophy_signs";
public static final int MAX_SIGNS = 16_384;
private static final Codec<Config> CONFIG_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("dimension").forGetter(Config::dimension),
Codec.LONG.fieldOf("pos").forGetter(Config::pos),
Codec.STRING.fieldOf("owner").forGetter(Config::owner),
Codec.STRING.optionalFieldOf("owner_name", "?").forGetter(Config::ownerName),
Identifier.CODEC.listOf(0, TrophyPanelRules.MAX_PAGES).optionalFieldOf("pages", List.of())
.forGetter(Config::pages),
Codec.INT.optionalFieldOf("current_page", 0).forGetter(Config::currentPage),
Codec.STRING.optionalFieldOf("display", TrophyDisplayMode.NAMES_AND_VALUES.key())
.forGetter(Config::display),
Codec.BOOL.optionalFieldOf("configured", false).forGetter(Config::configured)
).apply(instance, Config::new));
private static final Codec<TrophySignData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.INT.optionalFieldOf("data_version", DATA_VERSION).forGetter(data -> data.dataVersion),
CONFIG_CODEC.listOf().optionalFieldOf("signs", List.of()).forGetter(TrophySignData::entriesForCodec)
).apply(instance, TrophySignData::new));
private static final SavedDataType<TrophySignData> TYPE = new SavedDataType<>(
RedStonerMod.id(SAVE_KEY), TrophySignData::new, CODEC, null);
private int dataVersion;
private final List<Config> signs;
public TrophySignData() {
this(DATA_VERSION, List.of());
}
private TrophySignData(int dataVersion, List<Config> signs) {
this.dataVersion = dataVersion;
this.signs = new ArrayList<>(signs == null ? List.of()
: signs.stream().limit(MAX_SIGNS).toList());
}
public static TrophySignData get(MinecraftServer server) {
ServerLevel overworld = server.getLevel(Level.OVERWORLD);
if (overworld == null) throw new IllegalStateException("Overworld unavailable for trophy-sign data");
TrophySignData data = overworld.getDataStorage().computeIfAbsent(TYPE);
data.upgradeIfRequired();
return data;
}
public boolean writable() {
return dataVersion <= DATA_VERSION;
}
public List<Config> signs() {
return List.copyOf(signs);
}
public Optional<Config> signAt(ServerLevel level, BlockPos pos) {
return signAt(dimension(level), pos.asLong());
}
public Optional<Config> signAt(String dimension, long pos) {
return signs.stream().filter(sign -> sign.dimension().equals(dimension) && sign.pos() == pos).findFirst();
}
public boolean add(Config config) {
if (!writable() || signs.size() >= MAX_SIGNS
|| signAt(config.dimension(), config.pos()).isPresent()) return false;
signs.add(config);
setDirty();
return true;
}
public boolean replace(Config replacement) {
if (!writable()) return false;
for (int index = 0; index < signs.size(); index++) {
Config current = signs.get(index);
if (!current.dimension().equals(replacement.dimension()) || current.pos() != replacement.pos()) continue;
signs.set(index, replacement);
setDirty();
return true;
}
return false;
}
public boolean remove(Config config) {
if (!writable()) return false;
boolean removed = signs.remove(config);
if (removed) setDirty();
return removed;
}
private List<Config> entriesForCodec() {
return List.copyOf(signs);
}
private void upgradeIfRequired() {
if (dataVersion < DATA_VERSION) {
int previous = dataVersion;
dataVersion = DATA_VERSION;
setDirty();
RedStonerMod.LOGGER.info("[{}] Migrated trophy-sign data from v{} to v{}.",
RedStonerMod.DISPLAY_NAME, previous, dataVersion);
} else if (dataVersion > DATA_VERSION) {
RedStonerMod.LOGGER.warn("[{}] Trophy-sign data v{} is newer than supported v{}.",
RedStonerMod.DISPLAY_NAME, dataVersion, DATA_VERSION);
}
}
public static String dimension(ServerLevel level) {
return level.dimension().identifier().toString();
}
public record Config(String dimension, long pos, String owner, String ownerName,
List<Identifier> pages, int currentPage, String display, boolean configured) {
public Config {
dimension = clean(dimension, 128, "minecraft:overworld");
owner = cleanUuid(owner);
ownerName = clean(ownerName, 32, "?");
pages = TrophyPanelRules.sanitizeMetricIds(pages);
currentPage = pages.isEmpty() ? 0 : Math.clamp(currentPage, 0, pages.size() - 1);
display = TrophyDisplayMode.byKey(display).key();
configured = configured && !pages.isEmpty();
}
public static Config draft(ServerLevel level, BlockPos pos, UUID ownerId, String ownerName) {
return new Config(TrophySignData.dimension(level), pos.asLong(), ownerId.toString(), ownerName,
List.of(), 0, TrophyDisplayMode.NAMES_AND_VALUES.key(), false);
}
public UUID ownerId() {
return UUID.fromString(owner);
}
public TrophyDisplayMode displayMode() {
return TrophyDisplayMode.byKey(display);
}
public Identifier currentMetric() {
return pages.get(Math.clamp(currentPage, 0, pages.size() - 1));
}
public Config withConfiguration(List<Identifier> requestedPages, TrophyDisplayMode mode,
String retainedOwnerName) {
List<Identifier> sanitized = TrophyPanelRules.sanitizeMetricIds(requestedPages);
return new Config(dimension, pos, owner, retainedOwnerName, sanitized, 0, mode.key(), true);
}
public Config withCurrentPage(int page) {
return new Config(dimension, pos, owner, ownerName, pages, page, display, configured);
}
}
private static String clean(String value, int maximum, String fallback) {
String clean = value == null || value.isBlank() ? fallback : value.strip();
return clean.substring(0, Math.min(maximum, clean.length()));
}
private static String cleanUuid(String value) {
try {
return UUID.fromString(value).toString();
} catch (IllegalArgumentException | NullPointerException ignored) {
return new UUID(0L, 0L).toString();
}
}
}
@@ -0,0 +1,30 @@
package fr.koka99cab.sanctuary26.redstoner.trophy;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.SignItem;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.SignBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/** The historical trophy-panel item now places an ordinary standing or wall sign. */
public final class TrophySignItem extends SignItem {
public TrophySignItem(Block standing, Block wall, Properties properties) {
super(standing, wall, properties);
}
@Override
protected boolean updateCustomBlockEntityTag(BlockPos pos, Level level, Player player,
ItemStack stack, BlockState state) {
if (level instanceof ServerLevel serverLevel && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(pos) instanceof SignBlockEntity sign) {
TrophySignService.activatePlacedSign(serverPlayer, serverLevel, pos, sign);
}
// The bounded trophy editor replaces the vanilla text editor.
return true;
}
}
@@ -0,0 +1,271 @@
package fr.koka99cab.sanctuary26.redstoner.trophy;
import fr.koka99cab.sanctuary26.redstoner.network.OpenTrophySignEditorPayload;
import fr.koka99cab.sanctuary26.redstoner.network.SaveTrophySignPayload;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
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.List;
import java.util.UUID;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.ChatFormatting;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.SignBlock;
import net.minecraft.world.level.block.WallSignBlock;
import net.minecraft.world.level.block.entity.SignBlockEntity;
import net.minecraft.world.level.block.entity.SignText;
/** Server-authoritative ownership, paging, editing and refresh behavior for white vanilla trophy signs. */
public final class TrophySignService {
private static final int REFRESH_INTERVAL_TICKS = 20;
private static final int MAX_INSPECTIONS_PER_REFRESH = 128;
private static int tickCounter;
private static int refreshCursor;
private static boolean initialized;
private TrophySignService() {}
public static void initialize() {
if (initialized) return;
initialized = true;
UseBlockCallback.EVENT.register((player, level, hand, hit) -> {
BlockPos pos = hit.getBlockPos();
if (hand != InteractionHand.MAIN_HAND
|| !RedStonerRegistries.isTrophySign(level.getBlockState(pos).getBlock())
|| !(level.getBlockEntity(pos) instanceof SignBlockEntity sign)) {
return InteractionResult.PASS;
}
if (level.isClientSide()) return InteractionResult.SUCCESS;
return player instanceof ServerPlayer serverPlayer
? interact(serverPlayer, (ServerLevel) level, pos, sign)
: InteractionResult.PASS;
});
PlayerBlockBreakEvents.BEFORE.register((level, player, pos, state, blockEntity) -> {
if (!(level instanceof ServerLevel serverLevel) || !RedStonerRegistries.isTrophySign(state.getBlock())) {
return true;
}
TrophySignData.Config config = TrophySignData.get(serverLevel.getServer())
.signAt(serverLevel, pos).orElse(null);
if (config == null || config.ownerId().equals(player.getUUID())) return true;
if (player instanceof ServerPlayer serverPlayer && isOperator(serverPlayer)) return true;
if (player instanceof ServerPlayer serverPlayer) message(serverPlayer, "protected");
return false;
});
PlayerBlockBreakEvents.AFTER.register((level, player, pos, state, blockEntity) -> {
if (!(level instanceof ServerLevel serverLevel) || !RedStonerRegistries.isTrophySign(state.getBlock())) {
return;
}
TrophySignData data = TrophySignData.get(serverLevel.getServer());
data.signAt(serverLevel, pos).ifPresent(data::remove);
});
ServerTickEvents.END_SERVER_TICK.register(TrophySignService::tick);
}
/** Creates a blank owned sign and immediately opens its custom editor. */
public static void activatePlacedSign(ServerPlayer player, ServerLevel level, BlockPos pos,
SignBlockEntity sign) {
TrophySignData data = TrophySignData.get(level.getServer());
TrophySignData.Config existing = data.signAt(level, pos).orElse(null);
if (existing == null) {
TrophySignData.Config draft = TrophySignData.Config.draft(level, pos, player.getUUID(),
player.getGameProfile().name());
if (!data.add(draft)) {
message(player, data.writable() ? "registry_full" : "newer_data");
return;
}
existing = draft;
}
writeBlankSign(level, sign);
openEditor(player, existing);
}
private static InteractionResult interact(ServerPlayer player, ServerLevel level, BlockPos pos,
SignBlockEntity sign) {
TrophySignData data = TrophySignData.get(level.getServer());
TrophySignData.Config config = data.signAt(level, pos).orElse(null);
if (config == null) {
activatePlacedSign(player, level, pos, sign);
return InteractionResult.SUCCESS_SERVER;
}
boolean canConfigure = TrophyPanelRules.canConfigure(config.ownerId(), player.getUUID(), isOperator(player));
if (!config.configured() || player.isSecondaryUseActive()) {
if (canConfigure) openEditor(player, config);
else message(player, "not_owner");
return InteractionResult.SUCCESS_SERVER;
}
int page = TrophyPanelRules.nextPage(config.currentPage(), config.pages().size());
TrophySignData.Config advanced = config.withCurrentPage(page);
if (!data.replace(advanced)) {
message(player, "newer_data");
return InteractionResult.CONSUME;
}
writeSign(level, sign, advanced);
message(player, "page", advanced.currentPage() + 1, advanced.pages().size());
return InteractionResult.SUCCESS_SERVER;
}
public static void save(ServerPlayer player, SaveTrophySignPayload payload) {
if (player.distanceToSqr(net.minecraft.world.phys.Vec3.atCenterOf(payload.pos())) > 64.0D) {
message(player, "too_far");
return;
}
ServerLevel level = player.level();
if (!RedStonerRegistries.isTrophySign(level.getBlockState(payload.pos()).getBlock())
|| !(level.getBlockEntity(payload.pos()) instanceof SignBlockEntity sign)) {
message(player, "missing");
return;
}
TrophySignData data = TrophySignData.get(level.getServer());
TrophySignData.Config config = data.signAt(level, payload.pos()).orElse(null);
if (config == null || !TrophyPanelRules.canConfigure(config.ownerId(), player.getUUID(), isOperator(player))) {
message(player, "not_owner");
return;
}
List<Identifier> pages = TrophyPanelRules.sanitizeMetricIds(payload.pages()).stream()
.filter(SanctuaryRankingApi::isConfigurableMetric).toList();
if (pages.isEmpty() || pages.size() != payload.pages().size()) {
message(player, "invalid");
return;
}
TrophySignData.Config configured = config.withConfiguration(pages,
TrophyDisplayMode.byKey(payload.display()), config.ownerName());
if (!data.replace(configured)) {
message(player, "newer_data");
return;
}
writeSign(level, sign, configured);
message(player, "saved");
}
public static boolean migrateLegacy(ServerLevel level, BlockPos pos, Direction facing, UUID owner,
String ownerName, List<Identifier> requestedPages, int currentPage) {
TrophySignData data = TrophySignData.get(level.getServer());
if (!data.writable()) return false;
UUID retainedOwner = owner == null ? new UUID(0L, 0L) : owner;
TrophySignData.Config previous = data.signAt(level, pos).orElse(null);
if (previous == null && data.signs().size() >= TrophySignData.MAX_SIGNS) return false;
List<Identifier> pages = TrophyPanelRules.sanitizeMetricIds(requestedPages).stream()
.filter(SanctuaryRankingApi::isConfigurableMetric).toList();
if (pages.isEmpty()) pages = List.of(SanctuaryRankingApi.defaultMetric().id());
var state = RedStonerRegistries.TROPHY_WALL_SIGN.defaultBlockState()
.setValue(WallSignBlock.FACING, facing)
.setValue(SignBlock.WATERLOGGED, false);
if (!state.canSurvive(level, pos) || !level.setBlock(pos, state, Block.UPDATE_ALL)) return false;
TrophySignData.Config migrated = new TrophySignData.Config(TrophySignData.dimension(level), pos.asLong(),
retainedOwner.toString(), ownerName, pages, currentPage, TrophyDisplayMode.NAMES_AND_VALUES.key(), true);
boolean stored = previous == null ? data.add(migrated) : data.replace(migrated);
if (!stored) return false;
if (level.getBlockEntity(pos) instanceof SignBlockEntity sign) writeSign(level, sign, migrated);
return true;
}
private static void openEditor(ServerPlayer player, TrophySignData.Config config) {
if (!ServerPlayNetworking.canSend(player, OpenTrophySignEditorPayload.TYPE)) {
message(player, "client_required");
return;
}
ServerPlayNetworking.send(player, new OpenTrophySignEditorPayload(
BlockPos.of(config.pos()), config.pages(), config.display()));
}
private static void tick(MinecraftServer server) {
if (++tickCounter < REFRESH_INTERVAL_TICKS) return;
tickCounter = 0;
TrophySignData data = TrophySignData.get(server);
List<TrophySignData.Config> signs = data.signs();
if (signs.isEmpty()) {
refreshCursor = 0;
return;
}
int inspections = Math.min(MAX_INSPECTIONS_PER_REFRESH, signs.size());
for (int offset = 0; offset < inspections; offset++) {
TrophySignData.Config config = signs.get(Math.floorMod(refreshCursor + offset, signs.size()));
ServerLevel level = level(server, config.dimension());
BlockPos pos = BlockPos.of(config.pos());
if (level == null || !level.isLoaded(pos)) continue;
if (!RedStonerRegistries.isTrophySign(level.getBlockState(pos).getBlock())
|| !(level.getBlockEntity(pos) instanceof SignBlockEntity sign)) {
data.remove(config);
continue;
}
if (config.configured()) writeSign(level, sign, config);
else writeBlankSign(level, sign);
}
refreshCursor = Math.floorMod(refreshCursor + inspections, Math.max(1, signs.size()));
}
private static void writeBlankSign(ServerLevel level, SignBlockEntity sign) {
writeText(level, sign, new SignText());
}
private static void writeSign(ServerLevel level, SignBlockEntity sign, TrophySignData.Config config) {
Identifier metricId = config.currentMetric();
RankingSnapshot snapshot = SanctuaryRankingApi.snapshot(level.getServer(), metricId, config.ownerId());
RankingMetric metric = snapshot.metric();
RankingUnit unit = metric.unit();
SignText text = new SignText().setMessage(0,
Component.translatable("block.redstoner.trophy_sign.page_title",
config.currentPage() + 1, config.pages().size(), SanctuaryRankingApi.displayName(metric))
.withStyle(ChatFormatting.DARK_GRAY, ChatFormatting.BOLD));
if (snapshot.podium().isEmpty()) {
text = text.setMessage(1, Component.translatable("block.redstoner.trophy_sign.no_score")
.withStyle(ChatFormatting.GRAY));
} else {
for (int index = 0; index < TrophyPanelRules.SIGN_ENTRIES_PER_PAGE
&& index < snapshot.podium().size(); index++) {
RankingSnapshot.Entry entry = snapshot.podium().get(index);
text = text.setMessage(index + 1, rankingLine(config.displayMode(), entry, unit));
}
}
writeText(level, sign, text);
}
private static Component rankingLine(TrophyDisplayMode mode, RankingSnapshot.Entry entry, RankingUnit unit) {
return switch (mode) {
case NAMES_AND_VALUES -> Component.translatable("block.redstoner.trophy_sign.names_and_values",
entry.rank(), entry.playerName(), unit.format(entry.value()));
case NAMES -> Component.translatable("block.redstoner.trophy_sign.names",
entry.rank(), entry.playerName());
case VALUES -> Component.translatable("block.redstoner.trophy_sign.values",
entry.rank(), unit.format(entry.value()));
};
}
private static void writeText(ServerLevel level, SignBlockEntity sign, SignText text) {
sign.setText(text, true);
sign.setText(text, false);
sign.setWaxed(true);
sign.setChanged();
level.sendBlockUpdated(sign.getBlockPos(), sign.getBlockState(), sign.getBlockState(), 3);
}
private static ServerLevel level(MinecraftServer server, String dimension) {
for (ServerLevel level : server.getAllLevels()) {
if (TrophySignData.dimension(level).equals(dimension)) return level;
}
return null;
}
private static boolean isOperator(ServerPlayer player) {
return player.createCommandSourceStack().permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER);
}
private static void message(ServerPlayer player, String key, Object... args) {
player.sendSystemMessage(Component.translatable("message.redstoner.trophy_sign." + key, args));
}
}
@@ -0,0 +1,8 @@
{
"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 }
}
}
@@ -0,0 +1,20 @@
{
"variants": {
"rotation=0": { "model": "redstoner:block/trophy_sign_rot_0" },
"rotation=1": { "model": "redstoner:block/trophy_sign_rot_1" },
"rotation=2": { "model": "redstoner:block/trophy_sign_rot_2" },
"rotation=3": { "model": "redstoner:block/trophy_sign_rot_3" },
"rotation=4": { "model": "redstoner:block/trophy_sign_rot_0", "y": 90 },
"rotation=5": { "model": "redstoner:block/trophy_sign_rot_1", "y": 90 },
"rotation=6": { "model": "redstoner:block/trophy_sign_rot_2", "y": 90 },
"rotation=7": { "model": "redstoner:block/trophy_sign_rot_3", "y": 90 },
"rotation=8": { "model": "redstoner:block/trophy_sign_rot_0", "y": 180 },
"rotation=9": { "model": "redstoner:block/trophy_sign_rot_1", "y": 180 },
"rotation=10": { "model": "redstoner:block/trophy_sign_rot_2", "y": 180 },
"rotation=11": { "model": "redstoner:block/trophy_sign_rot_3", "y": 180 },
"rotation=12": { "model": "redstoner:block/trophy_sign_rot_0", "y": 270 },
"rotation=13": { "model": "redstoner:block/trophy_sign_rot_1", "y": 270 },
"rotation=14": { "model": "redstoner:block/trophy_sign_rot_2", "y": 270 },
"rotation=15": { "model": "redstoner:block/trophy_sign_rot_3", "y": 270 }
}
}
@@ -0,0 +1,8 @@
{
"variants": {
"facing=east": { "model": "redstoner:block/trophy_wall_sign", "y": 270 },
"facing=north": { "model": "redstoner:block/trophy_wall_sign", "y": 180 },
"facing=south": { "model": "redstoner:block/trophy_wall_sign" },
"facing=west": { "model": "redstoner:block/trophy_wall_sign", "y": 90 }
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "redstoner:item/trophy_sign"
}
}
@@ -55,8 +55,56 @@
"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."
"screen.redstoner.storage_terminal.error.locked": "The network contains a locked storage block.",
"block.redstoner.trophy_panel": "Trophy Panel",
"block.redstoner.trophy_sign": "Trophy Sign",
"block.redstoner.trophy_wall_sign": "Wall Trophy Sign",
"block.redstoner.trophy_sign.page_title": "[%s/%s] %s",
"block.redstoner.trophy_sign.names_and_values": "#%s %s · %s",
"block.redstoner.trophy_sign.names": "#%s %s",
"block.redstoner.trophy_sign.values": "#%s %s",
"block.redstoner.trophy_sign.no_score": "No score yet",
"screen.redstoner.trophy_sign.title": "Configure Trophy Sign",
"screen.redstoner.trophy_sign.search": "Search for a statistic…",
"screen.redstoner.trophy_sign.available": "Available statistics",
"screen.redstoner.trophy_sign.pages": "Pages %s/%s",
"screen.redstoner.trophy_sign.remove": "Remove",
"screen.redstoner.trophy_sign.display": "Display: %s",
"screen.redstoner.trophy_sign.display.names_and_values": "names and values",
"screen.redstoner.trophy_sign.display.names": "names only",
"screen.redstoner.trophy_sign.display.values": "values only",
"message.redstoner.trophy_sign.not_owner": "Only the creator or an operator can configure this sign.",
"message.redstoner.trophy_sign.protected": "Only the creator or an operator can break this sign.",
"message.redstoner.trophy_sign.saved": "Trophy sign configured.",
"message.redstoner.trophy_sign.page": "Page %s/%s",
"message.redstoner.trophy_sign.invalid": "The configuration contains an invalid statistic.",
"message.redstoner.trophy_sign.missing": "This trophy sign no longer exists.",
"message.redstoner.trophy_sign.too_far": "You are too far away from this sign.",
"message.redstoner.trophy_sign.client_required": "An up-to-date Red-Stoner client is required to configure this sign.",
"message.redstoner.trophy_sign.registry_full": "This world has reached its trophy-sign limit.",
"message.redstoner.trophy_sign.newer_data": "This sign data comes from a newer version and remains read-only.",
"block.redstoner.trophy_panel.page_title": "[%s/%s] %s",
"block.redstoner.trophy_panel.podium": "#%s %s · %s",
"block.redstoner.trophy_panel.player": "#%s %s",
"block.redstoner.trophy_panel.value": "#%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"
}
@@ -55,8 +55,56 @@
"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é."
"screen.redstoner.storage_terminal.error.locked": "Le réseau contient un stockage verrouillé.",
"block.redstoner.trophy_panel": "Panneau de trophées",
"block.redstoner.trophy_sign": "Panneau de trophées",
"block.redstoner.trophy_wall_sign": "Panneau mural de trophées",
"block.redstoner.trophy_sign.page_title": "[%s/%s] %s",
"block.redstoner.trophy_sign.names_and_values": "#%s %s · %s",
"block.redstoner.trophy_sign.names": "#%s %s",
"block.redstoner.trophy_sign.values": "#%s %s",
"block.redstoner.trophy_sign.no_score": "Aucun score",
"screen.redstoner.trophy_sign.title": "Configurer le panneau de trophées",
"screen.redstoner.trophy_sign.search": "Rechercher une statistique…",
"screen.redstoner.trophy_sign.available": "Statistiques disponibles",
"screen.redstoner.trophy_sign.pages": "Pages %s/%s",
"screen.redstoner.trophy_sign.remove": "Retirer",
"screen.redstoner.trophy_sign.display": "Affichage : %s",
"screen.redstoner.trophy_sign.display.names_and_values": "noms et valeurs",
"screen.redstoner.trophy_sign.display.names": "noms seulement",
"screen.redstoner.trophy_sign.display.values": "valeurs seulement",
"message.redstoner.trophy_sign.not_owner": "Seul le créateur ou un opérateur peut configurer ce panneau.",
"message.redstoner.trophy_sign.protected": "Seul le créateur ou un opérateur peut casser ce panneau.",
"message.redstoner.trophy_sign.saved": "Panneau de trophées configuré.",
"message.redstoner.trophy_sign.page": "Page %s/%s",
"message.redstoner.trophy_sign.invalid": "La configuration contient une statistique invalide.",
"message.redstoner.trophy_sign.missing": "Ce panneau de trophées nexiste plus.",
"message.redstoner.trophy_sign.too_far": "Tu es trop loin de ce panneau.",
"message.redstoner.trophy_sign.client_required": "Un client Red-Stoner à jour est requis pour configurer ce panneau.",
"message.redstoner.trophy_sign.registry_full": "La limite de panneaux de trophées du monde est atteinte.",
"message.redstoner.trophy_sign.newer_data": "Ces données de panneau proviennent dune version plus récente et restent en lecture seule.",
"block.redstoner.trophy_panel.page_title": "[%s/%s] %s",
"block.redstoner.trophy_panel.podium": "#%s %s · %s",
"block.redstoner.trophy_panel.player": "#%s %s",
"block.redstoner.trophy_panel.value": "#%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 ; lobjet na 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"
}
@@ -55,8 +55,56 @@
"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": "В сети есть запертое хранилище."
"screen.redstoner.storage_terminal.error.locked": "В сети есть запертое хранилище.",
"block.redstoner.trophy_panel": "Панель трофеев",
"block.redstoner.trophy_sign": "Табличка трофеев",
"block.redstoner.trophy_wall_sign": "Настенная табличка трофеев",
"block.redstoner.trophy_sign.page_title": "[%s/%s] %s",
"block.redstoner.trophy_sign.names_and_values": "#%s %s · %s",
"block.redstoner.trophy_sign.names": "#%s %s",
"block.redstoner.trophy_sign.values": "#%s %s",
"block.redstoner.trophy_sign.no_score": "Результатов пока нет",
"screen.redstoner.trophy_sign.title": "Настройка таблички трофеев",
"screen.redstoner.trophy_sign.search": "Найти статистику…",
"screen.redstoner.trophy_sign.available": "Доступная статистика",
"screen.redstoner.trophy_sign.pages": "Страницы %s/%s",
"screen.redstoner.trophy_sign.remove": "Удалить",
"screen.redstoner.trophy_sign.display": "Отображение: %s",
"screen.redstoner.trophy_sign.display.names_and_values": "имена и значения",
"screen.redstoner.trophy_sign.display.names": "только имена",
"screen.redstoner.trophy_sign.display.values": "только значения",
"message.redstoner.trophy_sign.not_owner": "Настраивать эту табличку может только создатель или оператор.",
"message.redstoner.trophy_sign.protected": "Сломать эту табличку может только создатель или оператор.",
"message.redstoner.trophy_sign.saved": "Табличка трофеев настроена.",
"message.redstoner.trophy_sign.page": "Страница %s/%s",
"message.redstoner.trophy_sign.invalid": "Настройка содержит недопустимую статистику.",
"message.redstoner.trophy_sign.missing": "Этой таблички трофеев больше нет.",
"message.redstoner.trophy_sign.too_far": "Вы слишком далеко от этой таблички.",
"message.redstoner.trophy_sign.client_required": "Для настройки нужен обновлённый клиент Red-Stoner.",
"message.redstoner.trophy_sign.registry_full": "Достигнут предел табличек трофеев для этого мира.",
"message.redstoner.trophy_sign.newer_data": "Данные таблички созданы более новой версией и доступны только для чтения.",
"block.redstoner.trophy_panel.page_title": "[%s/%s] %s",
"block.redstoner.trophy_panel.podium": "#%s %s · %s",
"block.redstoner.trophy_panel.player": "#%s %s",
"block.redstoner.trophy_panel.value": "#%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: результата нет"
}
@@ -0,0 +1,21 @@
{
"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" }
}
}
]
}
@@ -0,0 +1,7 @@
{
"parent": "minecraft:block/template_sign_rot_0",
"textures": {
"all": "minecraft:block/pale_oak_sign",
"particle": "minecraft:block/pale_oak_planks"
}
}
@@ -0,0 +1,7 @@
{
"parent": "minecraft:block/template_sign_rot_1",
"textures": {
"all": "minecraft:block/pale_oak_sign",
"particle": "minecraft:block/pale_oak_planks"
}
}
@@ -0,0 +1,7 @@
{
"parent": "minecraft:block/template_sign_rot_2",
"textures": {
"all": "minecraft:block/pale_oak_sign",
"particle": "minecraft:block/pale_oak_planks"
}
}

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