Compare commits

..
Author SHA1 Message Date
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 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
68 changed files with 3084 additions and 301 deletions
+30
View File
@@ -8,6 +8,36 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve
## Non publié
Modules modifiés : **Sanctuary `0.0.0-alpha.137`** et **Red-Stoner `0.0.0-alpha.18`**.
- transforme le panneau de trophées en plaque verticale de chêne, corrige le texte auparavant masqué et utilise les quatre lignes pour le titre paginé ainsi que les trois places du podium ;
- permet de passer à la page suivante 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 ;
- ne change ni le butin ordinaire des coffres, ni les données Sanctuary v15, ni le protocole réseau 24 ; les nouveaux coffres et lits apparaissent dans les chunks qui 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 ;
+14 -6
View File
@@ -12,19 +12,25 @@ 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 |
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.227`**. Cette release distribue Another World **`0.0.0-alpha.57`** et Sanctuary **`0.0.0-alpha.131`** ; les huit autres modules conservent leur version issue de l'alpha.226.
Version courante du pack distribué : **`26.2.0-alpha.230`**. Cette release distribue Sanctuary **`0.0.0-alpha.136`** et Red-Stoner **`0.0.0-alpha.17`** ; les huit autres modules conservent leur version issue de l'alpha.229.
Le prochain module Sanctuary **`0.0.0-alpha.132`** 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. Les chunks déjà générés restent intacts.
Le prochain Red-Stoner **`0.0.0-alpha.18`** présente le panneau de trophées comme une plaque verticale de chêne et rend enfin ses quatre lignes lisibles : titre et numéro de page, puis les trois places du podium. Un clic droit avance dune page et `Maj` + clic droit revient à la précédente ; les données v4 et le protocole réseau 7 restent inchangés.
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.
@@ -44,7 +50,7 @@ Lalpha.220 transforme le terminal de stockage en véritable menu de conteneur
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. 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.
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.
@@ -317,7 +323,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 sept blocs actifs sous son namespace : `eight_bit_computer`, `controller`, `personal_computer`, `drawer`, `particuleur`, `storage_terminal` et `trophy_panel`. Son onglet créatif expose aussi les trois appareils Canon et les seize styles de pellicule.
Le panneau mural `redstoner:trophy_panel` consomme lAPI publique de classement de Sanctuary sans conserver les scores. Le joueur qui le pose peut ajouter des pages statistiques en cliquant avec un item, tandis que tous les visiteurs peuvent faire défiler les pages. Sanctuary importe une fois les statistiques vanilla existantes, garde les classements par UUID — champion déconnecté compris — et fournit seulement un podium borné à trois entrées. Les composants privés de litem servant dicône et la base complète des joueurs ne sont jamais synchronisés au client.
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é.
+663 -223
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -14,7 +14,7 @@ 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.15
redstoner_version=0.0.0-alpha.18
redstoner_lifecycle=active
anotherworld_version=0.0.0-alpha.57
anotherworld_lifecycle=active
@@ -28,7 +28,7 @@ iliketomoveit_version=0.0.0-alpha.18
iliketomoveit_lifecycle=active
onlyfun_version=0.0.0-alpha.27
onlyfun_lifecycle=active
sanctuary_version=0.0.0-alpha.132
sanctuary_version=0.0.0-alpha.137
sanctuary_lifecycle=active
pack_version=26.2.0-alpha.227
pack_version=26.2.0-alpha.230
maven_group=fr.koka99cab.sanctuary26
@@ -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,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
}
}
+12 -12
View File
@@ -14,15 +14,15 @@ hash = "41f4f4c88c71d0a23446d5014f7407d55ad477441847099cad24492b032335a0"
[[files]]
file = "mods/ambiance-0.0.0-alpha.22.jar"
hash = "ac54481beb8c038482fd99ba1605dcc86c912751df64e590273e4608a5c981b8"
hash = "cdc60ed8f15ca910f41954d9acb6a74ac103e85c73f665fd1bc67be7eab1f15a"
[[files]]
file = "mods/anotherworld-0.0.0-alpha.57.jar"
hash = "3d825230943b5bb95ecfa40b1d948bf8ddfaaccd25e372f203b86e735dbdaa65"
hash = "634a122b406bd7206fd19947687fd7cb37e4cc4098822eecf3f0b7d1fea79d90"
[[files]]
file = "mods/events-0.0.0-alpha.0.jar"
hash = "2e9f170a91982cba15a91b5203eef96c2986c6a6c93f74f98fecca95a34430d2"
hash = "09bd8dfccca004de46ad3237728ef532de50d04c31b88f1e33721204d3c70cd8"
[[files]]
file = "mods/fabric-api.pw.toml"
@@ -31,7 +31,7 @@ metafile = true
[[files]]
file = "mods/iliketomoveit-0.0.0-alpha.18.jar"
hash = "21749e842ca20bb0a433356ac8515a47577e0c528f3e6f30da61dab11342cee2"
hash = "cd026c4735e945ec6cc19261165033dc10bcbbba75af412f4145e54437a61854"
[[files]]
file = "mods/iris.pw.toml"
@@ -40,7 +40,7 @@ metafile = true
[[files]]
file = "mods/itsalive-0.0.0-alpha.38.jar"
hash = "93970ce1392670c1398097e94c5992b9f635c9f68c9f5e8d23a9eac61f4392e3"
hash = "d905a05edbcb841b173ef5b0090fe7a117ec06e119fba7e184338fec7671fde6"
[[files]]
file = "mods/jei.pw.toml"
@@ -49,23 +49,23 @@ metafile = true
[[files]]
file = "mods/masterkey-0.0.0-alpha.9.jar"
hash = "dc4abe7a53b01b0b71de787701df8acf2c3979216a117b2d927e92ce8b210774"
hash = "cef973db96c7523fe828a2ca57ce1283ed1da3b1ae3c646a88ea601ecfe4e825"
[[files]]
file = "mods/onlyfun-0.0.0-alpha.27.jar"
hash = "14f230d2d3ebbfab4ceb34fc4087b4fb92b8764a65cc4f9ab007af8ee7084995"
hash = "1ff5b8853c5a813bbb6c6cf5787c8311d8e72760ed0e9c2adb1da897241e4e1e"
[[files]]
file = "mods/ouch-0.0.0-alpha.11.jar"
hash = "db1a98ddedce2411b5a2583e22c664c0eff3b5d0d8ee91a20a0e863bc5d4f559"
hash = "fd609cfe5f96ae1d2b41b2da7faf077047514772b56bbe130251db3bf72c2ac8"
[[files]]
file = "mods/redstoner-0.0.0-alpha.13.jar"
hash = "3fe72a3e31cc32f51b4992097e258fb1c21c7e3adeef5d40310e3d271ff0ef12"
file = "mods/redstoner-0.0.0-alpha.17.jar"
hash = "d7bef2bdf533d2c83f7b05549c01a77028dd5edc60418c7244447ca61160f216"
[[files]]
file = "mods/sanctuary-0.0.0-alpha.131.jar"
hash = "89b6d772e408c2162a2b68198b877409d4cbe79aeddbdc62a135f91d6e395a00"
file = "mods/sanctuary-0.0.0-alpha.136.jar"
hash = "973c12a3f26eb516bc1a053ebd18dab3f845645807448ff5638a1e9b26ba28bd"
[[files]]
file = "mods/simple-voice-chat.pw.toml"
+2 -2
View File
@@ -1,12 +1,12 @@
name = "Sanctuary 26.2"
author = "KOKA99CAB"
version = "26.2.0-alpha.227"
version = "26.2.0-alpha.230"
pack-format = "packwiz:1.1.0"
[index]
file = "index.toml"
hash-format = "sha256"
hash = "4e40f4d06d1f9f1b440a0be817a855a60861e3a2cf8041d6101a93a66e4c9097"
hash = "a78df91699f1ffa661e1bf59101891a217bd74334ee338b96824f5a9b745e443"
[versions]
fabric = "0.19.3"
+1 -1
View File
@@ -3,7 +3,7 @@
"game": "minecraft",
"name": "Sanctuary 26.2",
"summary": "Sanctuary 26.2 — progression, exploration, worldgen and multiplayer systems by KOKA99CAB.",
"versionId": "26.2.0-alpha.227",
"versionId": "26.2.0-alpha.230",
"files": [
{
"path": "mods/fabric-api-0.159.0+26.2.jar",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"pack_version": "26.2.0-alpha.227",
"pack_version": "26.2.0-alpha.230",
"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.227.mrpack",
"primary_file": "sanctuary-26.2.0-alpha.230.mrpack",
"shared_client_server_pack": true,
"update_owner": "packwiz-installer",
"check_on_launch": true,
@@ -13,7 +13,7 @@ import org.slf4j.LoggerFactory;
public final class RedStonerMod implements ModInitializer {
public static final String MOD_ID = "redstoner";
public static final String DISPLAY_NAME = "Red-Stoner";
public static final int CURRENT_DATA_VERSION = 3;
public static final int CURRENT_DATA_VERSION = 4;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public RedStonerMod() {
@@ -29,7 +29,7 @@ public final class RedStonerMod implements ModInitializer {
RedStonerRegistries.initialize();
RedStonerMenus.initialize();
RedStonerNetworking.initialize();
LOGGER.info("[{}] Computers, Drawer, Particuleur 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,282 @@
package fr.koka99cab.sanctuary26.redstoner.block.entity;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka99cab.sanctuary26.redstoner.block.TrophyPanelBlock;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyPanelRules;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingMetric;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingSnapshot;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingUnit;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.SanctuaryRankingApi;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.UUIDUtil;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.ItemOwner;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.world.phys.Vec3;
/** Persistent panel configuration plus a transient, bounded client-visible ranking snapshot. */
public final class TrophyPanelBlockEntity extends BlockEntity implements ItemOwner {
private static final int DATA_VERSION = 1;
private static final int REFRESH_INTERVAL_TICKS = 20;
private static final String CLIENT_SNAPSHOT_TAG = "VisibleSnapshot";
private static final Codec<Page> PAGE_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("metric").forGetter(Page::metricId),
ItemStack.OPTIONAL_CODEC.fieldOf("icon").forGetter(Page::icon)
).apply(instance, Page::new));
private static final Codec<ClientEntry> CLIENT_ENTRY_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("name").forGetter(ClientEntry::name),
Codec.LONG.fieldOf("value").forGetter(ClientEntry::value),
Codec.INT.fieldOf("rank").forGetter(ClientEntry::rank)
).apply(instance, ClientEntry::new));
private static final Codec<ClientSnapshot> CLIENT_SNAPSHOT_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("metric").forGetter(ClientSnapshot::metricId),
Codec.STRING.fieldOf("translation_key").forGetter(ClientSnapshot::translationKey),
Codec.STRING.fieldOf("unit").forGetter(ClientSnapshot::unitKey),
CLIENT_ENTRY_CODEC.listOf(0, RankingSnapshot.MAX_PODIUM_SIZE).fieldOf("podium")
.forGetter(ClientSnapshot::podium),
Codec.LONG.fieldOf("owner_value").forGetter(ClientSnapshot::ownerValue),
Codec.INT.fieldOf("owner_rank").forGetter(ClientSnapshot::ownerRank)
).apply(instance, ClientSnapshot::new));
private int dataVersion = DATA_VERSION;
private UUID owner;
private String ownerName = "?";
private List<Page> pages = List.of(defaultPage());
private int currentPage;
private long lastRefreshTick = Long.MIN_VALUE;
private ClientSnapshot visibleSnapshot = ClientSnapshot.empty(SanctuaryRankingApi.defaultMetric());
public TrophyPanelBlockEntity(BlockPos pos, BlockState state) {
super(RedStonerRegistries.TROPHY_PANEL_BLOCK_ENTITY, pos, state);
}
public static void serverTick(net.minecraft.world.level.Level level, BlockPos pos, BlockState state,
TrophyPanelBlockEntity panel) {
if (!(level instanceof ServerLevel serverLevel)) return;
long now = level.getGameTime();
if (panel.lastRefreshTick != Long.MIN_VALUE && now - panel.lastRefreshTick < REFRESH_INTERVAL_TICKS) return;
panel.lastRefreshTick = now;
panel.refresh(serverLevel);
}
public void claim(ServerPlayer player) {
if (!writable() || owner != null) return;
owner = player.getUUID();
ownerName = cleanName(player.getGameProfile().name());
syncPersistent();
}
public boolean canConfigure(ServerPlayer player, boolean operator) {
return writable() && TrophyPanelRules.canConfigure(owner, player.getUUID(), operator);
}
public ConfigureResult configure(ServerPlayer player, ItemStack stack) {
if (!writable()) return ConfigureResult.NEWER_DATA;
List<RankingMetric> offered = SanctuaryRankingApi.itemMetrics(stack);
if (offered.isEmpty()) return ConfigureResult.NO_METRIC;
List<Identifier> currentIds = pages.stream().map(Page::metricId).toList();
List<Identifier> mergedIds = TrophyPanelRules.mergeMetricIds(currentIds,
offered.stream().map(RankingMetric::id).toList());
List<Page> next = new ArrayList<>();
for (Identifier id : mergedIds) {
Page existing = pages.stream().filter(page -> page.metricId().equals(id)).findFirst().orElse(null);
next.add(existing == null ? new Page(id, stack.copyWithCount(1)) : existing);
}
pages = List.copyOf(next);
currentPage = Math.max(0, pages.indexOf(pages.stream()
.filter(page -> offered.stream().anyMatch(metric -> metric.id().equals(page.metricId())))
.findFirst().orElse(pages.getFirst())));
ownerName = cleanName(player.getGameProfile().name());
syncPersistent();
if (level instanceof ServerLevel serverLevel) refresh(serverLevel);
return ConfigureResult.SUCCESS;
}
public boolean nextPage() {
return movePage(1);
}
public boolean previousPage() {
return movePage(-1);
}
private boolean movePage(int offset) {
if (!writable() || pages.isEmpty()) return false;
currentPage = TrophyPanelRules.pageByOffset(currentPage, pages.size(), offset);
syncPersistent();
if (level instanceof ServerLevel serverLevel) refresh(serverLevel);
return true;
}
public Page currentPage() {
return pages.get(Math.clamp(currentPage, 0, pages.size() - 1));
}
public int pageNumber() {
return currentPage + 1;
}
public int pageCount() {
return pages.size();
}
public String ownerName() {
return ownerName;
}
public ClientSnapshot visibleSnapshot() {
return visibleSnapshot;
}
private void refresh(ServerLevel level) {
Page page = currentPage();
RankingSnapshot snapshot = SanctuaryRankingApi.snapshot(level.getServer(), page.metricId(), owner);
ClientSnapshot next = ClientSnapshot.from(snapshot);
if (next.equals(visibleSnapshot)) return;
visibleSnapshot = next;
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), Block.UPDATE_CLIENTS);
}
@Override
protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
output.putInt("DataVersion", dataVersion);
if (owner != null) output.store("Owner", UUIDUtil.CODEC, owner);
output.putString("OwnerName", ownerName);
output.store("Pages", PAGE_CODEC.listOf(0, TrophyPanelRules.MAX_PAGES), pages);
output.putInt("CurrentPage", currentPage);
}
@Override
protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
dataVersion = Math.max(DATA_VERSION, input.getIntOr("DataVersion", 0));
owner = input.read("Owner", UUIDUtil.CODEC).orElse(null);
ownerName = cleanName(input.getStringOr("OwnerName", "?"));
pages = input.read("Pages", PAGE_CODEC.listOf(0, TrophyPanelRules.MAX_PAGES))
.map(TrophyPanelBlockEntity::sanitizePages).orElseGet(() -> List.of(defaultPage()));
currentPage = Math.clamp(input.getIntOr("CurrentPage", 0), 0, pages.size() - 1);
visibleSnapshot = input.read(CLIENT_SNAPSHOT_TAG, CLIENT_SNAPSHOT_CODEC)
.orElseGet(() -> ClientSnapshot.empty(metricFor(currentPage().metricId())));
}
@Override
public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override
public CompoundTag getUpdateTag(HolderLookup.Provider lookup) {
CompoundTag tag = saveCustomOnly(lookup);
tag.store(CLIENT_SNAPSHOT_TAG, CLIENT_SNAPSHOT_CODEC, visibleSnapshot);
return tag;
}
@Override
public net.minecraft.world.level.Level level() {
return level;
}
@Override
public Vec3 position() {
return Vec3.atCenterOf(worldPosition);
}
@Override
public float getVisualRotationYInDegrees() {
return getBlockState().getValue(TrophyPanelBlock.FACING).toYRot();
}
private void syncPersistent() {
setChanged();
if (level != null) level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), Block.UPDATE_CLIENTS);
}
private boolean writable() {
return dataVersion <= DATA_VERSION;
}
private static List<Page> sanitizePages(List<Page> loaded) {
List<Page> valid = loaded.stream().filter(page -> SanctuaryRankingApi.metric(page.metricId()).isPresent())
.limit(TrophyPanelRules.MAX_PAGES).toList();
return valid.isEmpty() ? List.of(defaultPage()) : List.copyOf(valid);
}
private static Page defaultPage() {
return new Page(SanctuaryRankingApi.defaultMetric().id(), ItemStack.EMPTY);
}
private static RankingMetric metricFor(Identifier id) {
return SanctuaryRankingApi.metric(id).orElse(SanctuaryRankingApi.defaultMetric());
}
private static String cleanName(String name) {
if (name == null || name.isBlank()) return "?";
String clean = name.strip();
return clean.length() > 32 ? clean.substring(0, 32) : clean;
}
public enum ConfigureResult { SUCCESS, NO_METRIC, NEWER_DATA }
public record Page(Identifier metricId, ItemStack icon) {
public Page {
metricId = metricId == null ? SanctuaryRankingApi.defaultMetric().id() : metricId;
// The registry item is enough for an icon. Player components (containers, names, lore) never persist or sync.
icon = icon == null || icon.isEmpty() ? ItemStack.EMPTY : new ItemStack(icon.getItem());
}
}
public record ClientEntry(String name, long value, int rank) {
public ClientEntry {
name = cleanName(name);
value = Math.max(0L, value);
rank = Math.max(0, rank);
}
}
public record ClientSnapshot(Identifier metricId, String translationKey, String unitKey,
List<ClientEntry> podium, long ownerValue, int ownerRank) {
public ClientSnapshot {
translationKey = translationKey == null || translationKey.isBlank()
? "metric.sanctuary.ranking.unknown" : translationKey;
unitKey = RankingUnit.byKey(unitKey).key();
podium = podium == null ? List.of() : List.copyOf(podium.stream()
.limit(RankingSnapshot.MAX_PODIUM_SIZE).toList());
ownerValue = Math.max(0L, ownerValue);
ownerRank = Math.max(0, ownerRank);
}
static ClientSnapshot from(RankingSnapshot snapshot) {
return new ClientSnapshot(snapshot.metric().id(), snapshot.metric().translationKey(),
snapshot.metric().unit().key(), snapshot.podium().stream()
.map(entry -> new ClientEntry(entry.playerName(), entry.value(), entry.rank())).toList(),
snapshot.owner().value(), snapshot.owner().rank());
}
static ClientSnapshot empty(RankingMetric metric) {
return new ClientSnapshot(metric.id(), metric.translationKey(), metric.unit().key(), List.of(), 0L, 0);
}
public RankingUnit unit() {
return RankingUnit.byKey(unitKey);
}
}
}
@@ -5,6 +5,7 @@ import fr.koka99cab.sanctuary26.redstoner.client.gui.ComputerScreen;
import fr.koka99cab.sanctuary26.redstoner.client.gui.PersonalComputerScreen;
import fr.koka99cab.sanctuary26.redstoner.client.gui.StorageTerminalScreen;
import fr.koka99cab.sanctuary26.redstoner.client.renderer.DrawerBlockEntityRenderer;
import fr.koka99cab.sanctuary26.redstoner.client.renderer.TrophyPanelBlockEntityRenderer;
import fr.koka99cab.sanctuary26.redstoner.network.OpenComputerPayload;
import fr.koka99cab.sanctuary26.redstoner.network.OpenPersonalComputerPayload;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerMenus;
@@ -19,6 +20,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))));
@@ -2,12 +2,15 @@ package fr.koka99cab.sanctuary26.redstoner.client.gui;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalMenu;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalRules;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalSort;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalStatus;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.ChatFormatting;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.input.MouseButtonEvent;
@@ -28,8 +31,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,12 +45,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);
sortButton = addRenderableWidget(Button.builder(sortButtonLabel(), button -> requestSort())
.bounds(leftPos + 108, topPos + 2, 34, 14)
.tooltip(Tooltip.create(sortTooltip()))
.build());
}
@Override
@@ -151,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;
@@ -0,0 +1,89 @@
package fr.koka99cab.sanctuary26.redstoner.client.renderer;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import fr.koka99cab.sanctuary26.redstoner.block.TrophyPanelBlock;
import fr.koka99cab.sanctuary26.redstoner.block.entity.TrophyPanelBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.client.renderer.state.TrophyPanelRenderState;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingUnit;
import net.minecraft.client.gui.Font;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.network.chat.Component;
import net.minecraft.util.FormattedCharSequence;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.Vec3;
public final class TrophyPanelBlockEntityRenderer
implements BlockEntityRenderer<TrophyPanelBlockEntity, TrophyPanelRenderState> {
private static final int MAX_TEXT_WIDTH = 90;
private static final float TEXT_SCALE = 0.010416667F;
private static final float[] LINE_HEIGHTS = {0.70F, 0.59F, 0.48F, 0.37F};
private static final int TEXT_COLOR = 0xFF3D281A;
private final Font font;
public TrophyPanelBlockEntityRenderer(BlockEntityRendererProvider.Context context) {
font = context.font();
}
@Override
public TrophyPanelRenderState createRenderState() {
return new TrophyPanelRenderState();
}
@Override
public void extractRenderState(TrophyPanelBlockEntity panel, TrophyPanelRenderState state,
float tickProgress, Vec3 cameraPos, ModelFeatureRenderer.CrumblingOverlay crumblingOverlay) {
BlockEntityRenderer.super.extractRenderState(panel, state, tickProgress, cameraPos, crumblingOverlay);
state.facing = panel.getBlockState().getValue(TrophyPanelBlock.FACING);
ItemStack icon = panel.currentPage().icon();
TrophyPanelBlockEntity.ClientSnapshot snapshot = panel.visibleSnapshot();
RankingUnit unit = snapshot.unit();
Component metric = !icon.isEmpty() ? Component.translatable(snapshot.translationKey(), icon.getHoverName())
: Component.translatable(snapshot.translationKey());
state.lines[0] = Component.translatable("block.redstoner.trophy_panel.page_title",
panel.pageNumber(), panel.pageCount(), metric);
if (snapshot.podium().isEmpty()) {
state.lines[1] = Component.translatable("block.redstoner.trophy_panel.no_score");
state.lines[2] = Component.empty();
state.lines[3] = Component.empty();
} else {
for (int index = 0; index < 3; index++) {
if (index >= snapshot.podium().size()) {
state.lines[index + 1] = Component.empty();
continue;
}
TrophyPanelBlockEntity.ClientEntry entry = snapshot.podium().get(index);
state.lines[index + 1] = Component.translatable("block.redstoner.trophy_panel.podium",
entry.rank(), entry.name(), unit.format(entry.value()));
}
}
}
@Override
public void submit(TrophyPanelRenderState state, PoseStack poseStack,
SubmitNodeCollector submitNodeCollector, CameraRenderState cameraRenderState) {
float yRotation = -state.facing.toYRot();
for (int index = 0; index < TrophyPanelRenderState.LINE_COUNT; index++) {
submitText(state.lines[index], LINE_HEIGHTS[index], state, poseStack, submitNodeCollector, yRotation);
}
}
private void submitText(Component component, float y, TrophyPanelRenderState state,
PoseStack poseStack, SubmitNodeCollector submitNodeCollector, float yRotation) {
FormattedCharSequence text = font.split(component, MAX_TEXT_WIDTH).stream().findFirst()
.orElse(FormattedCharSequence.EMPTY);
float x = -font.width(text) / 2.0F;
poseStack.pushPose();
poseStack.translate(0.5F, y, 0.5F);
poseStack.mulPose(Axis.YP.rotationDegrees(yRotation));
poseStack.translate(0.0F, 0.0F, -0.331F);
poseStack.scale(TEXT_SCALE, -TEXT_SCALE, TEXT_SCALE);
submitNodeCollector.submitText(poseStack, x, 0.0F, text, false, Font.DisplayMode.POLYGON_OFFSET,
state.lightCoords, TEXT_COLOR, 0, 0);
poseStack.popPose();
}
}
@@ -0,0 +1,13 @@
package fr.koka99cab.sanctuary26.redstoner.client.renderer.state;
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
public final class TrophyPanelRenderState extends BlockEntityRenderState {
public static final int LINE_COUNT = 4;
public final Component[] lines = {
Component.empty(), Component.empty(), Component.empty(), Component.empty()
};
public Direction facing = Direction.NORTH;
}
@@ -6,10 +6,12 @@ import fr.koka99cab.sanctuary26.redstoner.block.DrawerBlock;
import fr.koka99cab.sanctuary26.redstoner.block.PersonalComputerBlock;
import fr.koka99cab.sanctuary26.redstoner.block.ParticuleurBlock;
import fr.koka99cab.sanctuary26.redstoner.block.StorageTerminalBlock;
import fr.koka99cab.sanctuary26.redstoner.block.TrophyPanelBlock;
import fr.koka99cab.sanctuary26.redstoner.block.entity.ComputerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.DrawerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.PersonalComputerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.ParticuleurBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.TrophyPanelBlockEntity;
import java.util.function.Function;
import net.fabricmc.fabric.api.event.player.AttackBlockCallback;
import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;
@@ -71,6 +73,12 @@ public final class RedStonerRegistries {
BlockBehaviour.Properties.of().mapColor(MapColor.COLOR_GRAY).strength(4.0F, 6.0F).sound(SoundType.METAL)
.requiresCorrectToolForDrops().noOcclusion().setId(blockKey(RedStonerMod.id("storage_terminal")))
);
public static final TrophyPanelBlock TROPHY_PANEL = registerBlock(
RedStonerMod.id("trophy_panel"),
TrophyPanelBlock::new,
BlockBehaviour.Properties.of().mapColor(MapColor.WOOD).strength(2.0F).sound(SoundType.WOOD)
.requiresCorrectToolForDrops().noOcclusion().setId(blockKey(RedStonerMod.id("trophy_panel")))
);
public static final ComputerBlock LEGACY_EIGHT_BIT_COMPUTER = registerBlock(
LEGACY_COMPUTER_ID, ComputerBlock::new, computerProperties(LEGACY_COMPUTER_ID, MapColor.COLOR_RED));
@@ -92,6 +100,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 = registerBlockItem(RedStonerMod.id("trophy_panel"), TROPHY_PANEL);
public static final Item LEGACY_EIGHT_BIT_COMPUTER_ITEM = registerBlockItem(LEGACY_COMPUTER_ID, LEGACY_EIGHT_BIT_COMPUTER);
public static final Item LEGACY_CONTROLLER_ITEM = registerBlockItem(LEGACY_CONTROLLER_ID, LEGACY_CONTROLLER);
@@ -115,6 +124,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 +138,7 @@ public final class RedStonerRegistries {
output.accept(CONTROLLER_ITEM);
output.accept(PARTICULEUR_ITEM);
output.accept(STORAGE_TERMINAL_ITEM);
output.accept(TROPHY_PANEL_ITEM);
})
.build()
);
@@ -142,7 +154,7 @@ public final class RedStonerRegistries {
}
initialized = true;
registerDrawerAttack();
RedStonerMod.LOGGER.info("[{}] Registered six active blocks and five legacy block aliases.", RedStonerMod.DISPLAY_NAME);
RedStonerMod.LOGGER.info("[{}] Registered seven active blocks and five legacy block aliases.", RedStonerMod.DISPLAY_NAME);
}
public static boolean isController(BlockState state) {
@@ -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
@@ -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,37 @@
package fr.koka99cab.sanctuary26.redstoner.trophy;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.UUID;
import net.minecraft.resources.Identifier;
/** Pure bounded rules shared by the trophy panel and its smoke tests. */
public final class TrophyPanelRules {
public static final int MAX_PAGES = 16;
private TrophyPanelRules() {}
public static boolean canConfigure(UUID owner, UUID actor, boolean operator) {
return operator || owner != null && owner.equals(actor);
}
public static int nextPage(int currentPage, int pageCount) {
return pageByOffset(currentPage, pageCount, 1);
}
public static int previousPage(int currentPage, int pageCount) {
return pageByOffset(currentPage, pageCount, -1);
}
public static int pageByOffset(int currentPage, int pageCount, int offset) {
return pageCount <= 0 ? 0 : Math.floorMod(currentPage + offset, pageCount);
}
public static List<Identifier> mergeMetricIds(List<Identifier> current, List<Identifier> offered) {
LinkedHashSet<Identifier> merged = new LinkedHashSet<>();
if (current != null) merged.addAll(current);
if (offered != null) merged.addAll(offered);
return List.copyOf(new ArrayList<>(merged).subList(0, Math.min(MAX_PAGES, merged.size())));
}
}
@@ -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,6 @@
{
"model": {
"type": "minecraft:model",
"model": "redstoner:block/trophy_panel"
}
}
@@ -55,8 +55,28 @@
"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_panel.page_title": "[%s/%s] %s",
"block.redstoner.trophy_panel.podium": "#%s %s · %s",
"block.redstoner.trophy_panel.no_score": "No score yet",
"block.redstoner.trophy_panel.champion": "#1 %s · %s",
"block.redstoner.trophy_panel.owner_rank": "%s · #%s · %s",
"block.redstoner.trophy_panel.owner_unranked": "%s · unranked",
"message.redstoner.trophy_panel.not_owner": "Only the owner or an operator can configure this panel.",
"message.redstoner.trophy_panel.configured": "Compatible pages added; the item was not consumed.",
"message.redstoner.trophy_panel.no_metric": "This item has no compatible statistic.",
"message.redstoner.trophy_panel.newer_data": "This panel uses a newer data format and remains read-only.",
"message.redstoner.trophy_panel.page": "Page %s/%s",
"message.redstoner.trophy_panel.header": "%s · page %s/%s",
"message.redstoner.trophy_panel.podium": "#%s %s · %s",
"message.redstoner.trophy_panel.owner_rank": "%s: rank #%s · %s",
"message.redstoner.trophy_panel.owner_unranked": "%s: no score"
}
@@ -55,8 +55,28 @@
"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_panel.page_title": "[%s/%s] %s",
"block.redstoner.trophy_panel.podium": "#%s %s · %s",
"block.redstoner.trophy_panel.no_score": "Aucun score",
"block.redstoner.trophy_panel.champion": "#1 %s · %s",
"block.redstoner.trophy_panel.owner_rank": "%s · #%s · %s",
"block.redstoner.trophy_panel.owner_unranked": "%s · non classé",
"message.redstoner.trophy_panel.not_owner": "Seul le propriétaire ou un opérateur peut configurer ce panneau.",
"message.redstoner.trophy_panel.configured": "Pages compatibles ajoutées ; 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,28 @@
"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_panel.page_title": "[%s/%s] %s",
"block.redstoner.trophy_panel.podium": "#%s %s · %s",
"block.redstoner.trophy_panel.no_score": "Результатов пока нет",
"block.redstoner.trophy_panel.champion": "#1 %s · %s",
"block.redstoner.trophy_panel.owner_rank": "%s · #%s · %s",
"block.redstoner.trophy_panel.owner_unranked": "%s · без места",
"message.redstoner.trophy_panel.not_owner": "Настраивать эту панель может только владелец или оператор.",
"message.redstoner.trophy_panel.configured": "Совместимые страницы добавлены; предмет не был израсходован.",
"message.redstoner.trophy_panel.no_metric": "У этого предмета нет совместимой статистики.",
"message.redstoner.trophy_panel.newer_data": "Панель использует более новый формат данных и доступна только для чтения.",
"message.redstoner.trophy_panel.page": "Страница %s/%s",
"message.redstoner.trophy_panel.header": "%s · страница %s/%s",
"message.redstoner.trophy_panel.podium": "#%s %s · %s",
"message.redstoner.trophy_panel.owner_rank": "%s: место #%s · %s",
"message.redstoner.trophy_panel.owner_unranked": "%s: результата нет"
}
@@ -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" }
}
}
]
}
@@ -1 +1 @@
{"replace":false,"values":["redstoner:eight_bit_computer","redstoner:controller","redstoner:personal_computer","redstoner:particuleur","redstoner:storage_terminal","redstone_computer:redstone_computer","redstone_computer:redstone_controller","redstone_computer:personal_computer","particuleur:particuleur"]}
{"replace":false,"values":["redstoner:eight_bit_computer","redstoner:controller","redstoner:personal_computer","redstoner:particuleur","redstoner:storage_terminal","redstoner:trophy_panel","redstone_computer:redstone_computer","redstone_computer:redstone_controller","redstone_computer:personal_computer","particuleur:particuleur"]}
@@ -1 +1 @@
{"replace":false,"values":["redstoner:eight_bit_computer","redstoner:controller","redstoner:personal_computer","redstoner:particuleur","redstoner:storage_terminal","redstone_computer:redstone_computer","redstone_computer:redstone_controller","redstone_computer:personal_computer","particuleur:particuleur"]}
{"replace":false,"values":["redstoner:eight_bit_computer","redstoner:controller","redstoner:personal_computer","redstoner:particuleur","redstoner:storage_terminal","redstoner:trophy_panel","redstone_computer:redstone_computer","redstone_computer:redstone_controller","redstone_computer:personal_computer","particuleur:particuleur"]}
@@ -0,0 +1,15 @@
{
"type": "minecraft:block",
"pools": [
{
"rolls": 1,
"entries": [
{ "type": "minecraft:item", "name": "redstoner:trophy_panel" }
],
"conditions": [
{ "condition": "minecraft:survives_explosion" }
]
}
],
"random_sequence": "redstoner:blocks/trophy_panel"
}
@@ -0,0 +1,19 @@
{
"type": "minecraft:crafting_shaped",
"category": "redstone",
"pattern": [
"NGN",
"GRG",
"NSN"
],
"key": {
"N": "minecraft:gold_nugget",
"G": "minecraft:black_stained_glass_pane",
"R": "minecraft:redstone_lamp",
"S": "minecraft:oak_sign"
},
"result": {
"id": "redstoner:trophy_panel",
"count": 1
}
}
+1 -1
View File
@@ -30,7 +30,7 @@
"sanctuary26": {
"system_type": "gameplay-system",
"namespace": "redstoner",
"data_version": 3,
"data_version": 4,
"network_protocol": 7,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
@@ -27,6 +27,7 @@ public final class StorageTerminalSmoke {
verifyPagination();
verifyAggregationAndTransfers();
verifySearch();
verifySorting();
verifyStableLayout();
}
@@ -149,6 +150,47 @@ public final class StorageTerminalSmoke {
.set(DataComponents.MAX_STACK_SIZE, 64).build());
}
private static void verifySorting() {
ItemStack eclair = new ItemStack(stackable(Items.STONE));
eclair.set(DataComponents.CUSTOM_NAME, Component.literal("Éclair"));
ItemStack banana = new ItemStack(stackable(Items.DIRT));
banana.set(DataComponents.CUSTOM_NAME, Component.literal("Banane"));
ItemStack zebra = new ItemStack(stackable(Items.DIAMOND));
zebra.set(DataComponents.CUSTOM_NAME, Component.literal("Zèbre"));
List<StorageTerminalInventory.Entry> entries = List.of(
new StorageTerminalInventory.Entry(eclair, 64),
new StorageTerminalInventory.Entry(banana, 8),
new StorageTerminalInventory.Entry(zebra, 2));
require(names(StorageTerminalSort.ALPHABETICAL.order(entries))
.equals(List.of("Banane", "Éclair", "Zèbre")),
"Alphabetical terminal sorting ignored names or accent folding");
require(names(StorageTerminalSort.COUNT_ASCENDING.order(entries))
.equals(List.of("Zèbre", "Banane", "Éclair")),
"Ascending terminal quantity sorting is incorrect");
require(names(StorageTerminalSort.COUNT_DESCENDING.order(entries))
.equals(List.of("Éclair", "Banane", "Zèbre")),
"Descending terminal quantity sorting is incorrect");
require(StorageTerminalSort.ALPHABETICAL.next() == StorageTerminalSort.COUNT_ASCENDING
&& StorageTerminalSort.COUNT_ASCENDING.next() == StorageTerminalSort.COUNT_DESCENDING
&& StorageTerminalSort.COUNT_DESCENDING.next() == StorageTerminalSort.ALPHABETICAL,
"Terminal sorting modes do not cycle deterministically");
StorageTerminalLayout stable = new StorageTerminalLayout();
stable.update(StorageTerminalSort.COUNT_DESCENDING.order(entries), true);
List<StorageTerminalInventory.Entry> changedCounts = List.of(
new StorageTerminalInventory.Entry(eclair, 1),
new StorageTerminalInventory.Entry(banana, 80),
new StorageTerminalInventory.Entry(zebra, 2));
stable.update(StorageTerminalSort.COUNT_DESCENDING.order(changedCounts), false);
require(names(stable.page(0).subList(0, 3)).equals(List.of("Éclair", "Banane", "Zèbre")),
"A periodic quantity refresh moved terminal entries under the cursor");
}
private static List<String> names(List<StorageTerminalInventory.Entry> entries) {
return entries.stream().map(entry -> entry.stack().getHoverName().getString()).toList();
}
private static void verifyStableLayout() {
StorageTerminalInventory.Entry stone = new StorageTerminalInventory.Entry(
new ItemStack(stackable(Items.STONE)), 64);
@@ -0,0 +1,37 @@
package fr.koka99cab.sanctuary26.redstoner.trophy;
import java.util.List;
import java.util.UUID;
import net.minecraft.resources.Identifier;
public final class TrophyPanelRulesSmoke {
private TrophyPanelRulesSmoke() {}
public static void main(String[] args) {
UUID owner = UUID.randomUUID();
UUID visitor = UUID.randomUUID();
require(TrophyPanelRules.canConfigure(owner, owner, false), "The owner cannot configure their panel");
require(!TrophyPanelRules.canConfigure(owner, visitor, false), "A visitor can configure another panel");
require(TrophyPanelRules.canConfigure(owner, visitor, true), "An operator cannot repair a panel");
require(TrophyPanelRules.nextPage(0, 3) == 1 && TrophyPanelRules.nextPage(2, 3) == 0,
"Public page cycling does not wrap");
require(TrophyPanelRules.previousPage(0, 3) == 2 && TrophyPanelRules.previousPage(2, 3) == 1,
"Reverse public page cycling does not wrap");
require(TrophyPanelRules.pageByOffset(4, 0, 1) == 0,
"An empty panel produced an invalid page index");
Identifier first = Identifier.parse("sanctuary:first");
Identifier second = Identifier.parse("sanctuary:second");
List<Identifier> offered = java.util.stream.IntStream.range(0, TrophyPanelRules.MAX_PAGES + 5)
.mapToObj(index -> Identifier.parse("sanctuary:metric_" + index)).toList();
List<Identifier> merged = TrophyPanelRules.mergeMetricIds(List.of(first, second, first), offered);
require(merged.size() == TrophyPanelRules.MAX_PAGES, "Panel pages are not bounded");
require(merged.get(0).equals(first) && merged.get(1).equals(second),
"Existing page order was not preserved");
require(merged.stream().distinct().count() == merged.size(), "Duplicate metric pages were retained");
}
private static void require(boolean condition, String message) {
if (!condition) throw new IllegalStateException(message);
}
}
+4 -1
View File
@@ -35,6 +35,7 @@ réelle matérialisée reçoit un identifiant persistant : elle peut passer du r
Une perte associée à un joueur lui reste réservée pendant 24 000 ticks avant de pouvoir rejoindre
les tirages historiques partagés ; une perte sans propriétaire est immédiatement partagée.
Chaque niveau génère exactement un coffre par cellule de 3 × 3 chunks dans les nouveaux chunks.
Tous les coffres vanilla de la Backroom, générés ou posés, deviennent des nœuds de butin renouvelés
toutes les 12 000 ticks lors de leur prochaine ouverture. Le renouvellement rend au registre les
objets perdus non pris, efface le contenu ordinaire et produit le plus souvent du vide ou des
@@ -75,7 +76,9 @@ numérotés de haut en bas afin quune chute fasse toujours avancer vers le ni
Les ouvertures entre deux niveaux sont calculées une seule fois puis appliquées à la fois au sol
supérieur et au plafond inférieur : une chute annoncée ne débouche donc pas sur un bloc fermé. Les
imperfections, morceaux suspendus et raccords volontairement cassés font partie de lesthétique,
mais les portes des couloirs restent traversables. Les lits de sortie générés sont désormais rares.
mais les portes des couloirs restent traversables. Chacun des cinq niveaux génère exactement un lit
de sortie par cellule de 3 × 3 chunks, sur un plancher praticable propre à sa zone. Il nest donc
plus nécessaire de parcourir une centaine de chunks ou de remonter jusquaux Archives pour ressortir.
La Backroom ne possède ni ciel, ni soleil, ni lune, ni étoiles, ni lumière ambiante. Son biome ne
déclare aucune créature et le serveur retire aussi toute entité de catégorie monstre qui y serait
+78 -15
View File
@@ -124,6 +124,14 @@ tasks.register("capeSyncPayloadSmoke", JavaExec) {
mainClass = "fr.koka99cab.sanctuary26.sanctuary.network.CapeSyncPayloadSmoke"
}
tasks.register("chickenArmorSmoke", JavaExec) {
group = "verification"
description = "Checks that only adult unarmored chickens accept armadillo-scute armor."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryChickenArmorSmoke"
}
tasks.register("inventorySortSmoke", JavaExec) {
group = "verification"
description = "Checks that storage stacks merge and sort while the hotbar row stays out of the sort."
@@ -199,7 +207,7 @@ tasks.register("verifyBlackMarket") {
def questData = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardData.java").text
def quests = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardService.java").text
def inventoryView = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/inventory/SanctuaryPlayerInventoryView.java").text
if (project.version.toString() != "0.0.0-alpha.132"
if (project.version.toString() != "0.0.0-alpha.137"
|| !data.contains('SAVE_KEY = "black_market"') || !data.contains("DATA_VERSION = 2")
|| !data.contains("ItemStack.CODEC.fieldOf(\"item\")") || !data.contains("replaceListing")
|| !service.contains("LISTING_FEE_RUBIES = 1") || !service.contains("COMMISSION_PERCENT = 5")
@@ -1208,7 +1216,7 @@ tasks.register("verifySanctuary") {
}
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (manifest.custom?.sanctuary26?.data_version != 14
if (manifest.custom?.sanctuary26?.data_version != 15
|| manifest.custom?.sanctuary26?.network_protocol != 24
|| manifest.depends?.jei != ">=30.24.0.176"
|| manifest.depends?."fabric-api" != ">=0.155.0+26.2"
@@ -2915,7 +2923,7 @@ tasks.register("verifyShopCurrencyRowsRelease") {
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/BlackMarketService.java").text
def shop = file(
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopService.java").text
if (project.version.toString() != "0.0.0-alpha.132"
if (project.version.toString() != "0.0.0-alpha.137"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.117"
|| migration.shop_currency_inventory?.all_unlocked_rows_counted != true
|| migration.shop_currency_inventory?.overflow_rows_counted != true
@@ -3081,7 +3089,7 @@ tasks.register("verifyProgressivePauseMenu") {
"pack/migrations/26.2.0-alpha.190-progressive-pause-menu-to-alpha.191.json"))
def pauseMenu = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/PauseScreenMixin.java").text
def costs = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/progression/SanctuaryProgressionCosts.java").text
if (project.version.toString() != "0.0.0-alpha.132"
if (project.version.toString() != "0.0.0-alpha.137"
|| migration.target?.pack_version != "26.2.0-alpha.191"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.109"
|| migration.pause_menu?.progression_full_width != true
@@ -3119,7 +3127,7 @@ tasks.register("verifyShopPricingLayout") {
"pack/migrations/26.2.0-alpha.192-shop-pricing-layout-to-alpha.193.json"))
def screen = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryShopScreen.java").text
def progression = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopProgression.java").text
if (project.version.toString() != "0.0.0-alpha.132"
if (project.version.toString() != "0.0.0-alpha.137"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.110"
|| migration.shop_layout?.locked_offer_buttons != 6
|| migration.shop_layout?.button_slots != [4, 5, 6, 7, 8, 9]
@@ -3150,7 +3158,7 @@ tasks.register("verifyShopCsvBossGates") {
"pack/migrations/26.2.0-alpha.194-shop-csv-boss-gates-to-alpha.195.json"))
def provider = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopCatalogProvider.java").text
def catalog = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopOfferCatalog.java").text
if (project.version.toString() != "0.0.0-alpha.132"
if (project.version.toString() != "0.0.0-alpha.137"
|| migration.target?.pack_version != "26.2.0-alpha.195"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.111"
|| migration.compatibility?.sanctuary_data_version != 11
@@ -3181,7 +3189,7 @@ tasks.register("verifyDeliveryBoxOrientation") {
def blockstate = new JsonSlurper().parse(
file("src/main/resources/assets/sanctuary/blockstates/delivery_box.json"))
def expectedVariants = ["facing=north", "facing=east", "facing=south", "facing=west"].toSet()
if (project.version.toString() != "0.0.0-alpha.132"
if (project.version.toString() != "0.0.0-alpha.137"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.112"
|| migration.mailbox?.id != "sanctuary:delivery_box"
|| migration.mailbox?.horizontal_facing != true
@@ -3209,7 +3217,7 @@ tasks.register("verifyInventoryShiftClick") {
def mixin = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/AbstractContainerMenuMixin.java").text
def screen = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/AbstractContainerScreenMixin.java").text
def gui = file("INVENTORY_GUI.md").text
if (project.version.toString() != "0.0.0-alpha.132"
if (project.version.toString() != "0.0.0-alpha.137"
|| !transfer.contains("shouldAppendOverflow")
|| !transfer.contains("isVanillaStorageRange")
|| !transfer.contains("hotbarStart")
@@ -3255,7 +3263,7 @@ tasks.register("verifyQuestRequests") {
new JsonSlurper().parse(file("src/main/resources/data/sanctuary/loot_table/blocks/${path}.json"))
.pools[0].entries[0].name
}
if (project.version.toString() != "0.0.0-alpha.132"
if (project.version.toString() != "0.0.0-alpha.137"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.113"
|| migration.quest_requests?.save_key != "quest_requests"
|| migration.quest_requests?.data_version != 1
@@ -3267,6 +3275,8 @@ tasks.register("verifyQuestRequests") {
|| !data.contains('SAVE_KEY = "quest_requests"') || !data.contains("DATA_VERSION = 3")
|| !service.contains("PlayerBlockBreakEvents.BEFORE")
|| !service.contains("player.blockPosition().distSqr(payload.pos()) > 64.0D")
|| !service.contains('"sign.sanctuary.quest_request.completed_by"')
|| !service.contains('"message.sanctuary.quest_request.details.completed_by"')
|| !network.contains("PROTOCOL_VERSION = 24")
|| !network.contains("QuestRequestSubmitPayload.ID")
|| !network.contains("QuestRequestAcceptPayload.ID")
@@ -3276,6 +3286,8 @@ tasks.register("verifyQuestRequests") {
!values.containsKey("block.sanctuary.quest_request_sign")
|| !values.containsKey("screen.sanctuary.quest_request.title")
|| !values.containsKey("message.sanctuary.quest_request.claimed")
|| !values.containsKey("message.sanctuary.quest_request.details.completed_by")
|| !values.containsKey("sign.sanctuary.quest_request.completed_by")
}) {
throw new GradleException("Sanctuary alpha.113 quest-request contract is incomplete")
}
@@ -3313,7 +3325,7 @@ tasks.register("verifyQuestRequestContracts") {
def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale ->
[(locale): new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${locale}.json"))]
}
if (project.version.toString() != "0.0.0-alpha.132"
if (project.version.toString() != "0.0.0-alpha.137"
|| migration.source?.pack_version != "26.2.0-alpha.198"
|| migration.source?.modules?.sanctuary != "0.0.0-alpha.114"
|| migration.target?.pack_version != "26.2.0-alpha.199"
@@ -3382,7 +3394,7 @@ tasks.register("verifyQuestRequestEscrow") {
def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale ->
[(locale): new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${locale}.json"))]
}
if (project.version.toString() != "0.0.0-alpha.132"
if (project.version.toString() != "0.0.0-alpha.137"
|| migration.source?.pack_version != "26.2.0-alpha.200"
|| migration.source?.modules?.sanctuary != "0.0.0-alpha.115"
|| migration.target?.pack_version != "26.2.0-alpha.201"
@@ -3461,7 +3473,7 @@ tasks.register("verifyCraftableBannerCape") {
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.132"
if (project.version.toString() != "0.0.0-alpha.137"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.130"
|| migration.white_cape?.crafting_material_count != 6
|| migration.white_cape?.maximum_pattern_layers != 6
@@ -3489,6 +3501,52 @@ tasks.named("verifySanctuary") {
dependsOn tasks.named("verifyCraftableBannerCape")
}
tasks.register("verifyFinalSanctuaryTickets") {
group = "verification"
description = "Checks the shield-mapped banner cape and armadillo-scute chicken armor contracts."
inputs.files(
file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryBannerCapeLayer.java"),
file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryChickenArmorLayer.java"),
file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryChickenArmor.java"),
file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/AnimalChickenArmorMixin.java"),
file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/LivingEntityChickenArmorMixin.java"),
file("src/main/resources/sanctuary.mixins.json"))
inputs.files(fileTree("src/main/resources/assets/sanctuary/lang") { include "*.json" })
doLast {
def capeRenderer = file(
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryBannerCapeLayer.java").text
def armorLayer = file(
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryChickenArmorLayer.java").text
def armorRules = file(
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryChickenArmor.java").text
def armorDamage = file(
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/LivingEntityChickenArmorMixin.java").text
def mixins = new JsonSlurper().parse(file("src/main/resources/sanctuary.mixins.json"))
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.137"
|| !capeRenderer.contains("SHIELD_PATTERN_X_SCALE = 1.2F")
|| !capeRenderer.contains("SHIELD_PATTERN_Y_SCALE = 1.375F")
|| !capeRenderer.contains("model, state, false, appearance.baseColor()")
|| !armorLayer.contains("EquipmentClientInfo.LayerType.WOLF_BODY")
|| !armorRules.contains("held.is(Items.WOLF_ARMOR)")
|| !armorRules.contains("DamageTypeTags.BYPASSES_WOLF_ARMOR")
|| !armorDamage.contains("armor.hurtAndBreak")
|| !mixins.mixins.contains("AnimalChickenArmorMixin")
|| !mixins.mixins.contains("LivingEntityChickenArmorMixin")
|| !mixins.client.contains("client.ChickenRendererMixin")
|| !mixins.client.contains("client.ChickenRenderStateMixin")
|| locales.any { !it.containsKey("tooltip.sanctuary.chicken_armor") }) {
throw new GradleException("Sanctuary alpha.136 final-ticket contract is incomplete")
}
}
}
tasks.named("verifySanctuary") {
dependsOn tasks.named("verifyFinalSanctuaryTickets")
}
tasks.register("verifyAlphaIndev") {
group = "verification"
description = "Checks the alpha.122 finite Indev world, migration and attribution contract."
@@ -3516,7 +3574,7 @@ tasks.register("verifyAlphaIndev") {
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.132"
if (project.version.toString() != "0.0.0-alpha.137"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.122"
|| migration.alpha_world?.dimension != "sanctuary:alpha"
|| migration.alpha_world?.legacy_dimension != "portals:alpha"
@@ -3576,7 +3634,7 @@ tasks.register("verifyBackroom") {
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.132"
if (project.version.toString() != "0.0.0-alpha.137"
|| migration.source_data_version != 13 || migration.target_data_version != 14
|| migration.integration_modules?.anotherworld?.source != "0.0.0-alpha.56"
|| migration.integration_modules?.anotherworld?.target != "0.0.0-alpha.57"
@@ -3602,16 +3660,20 @@ tasks.register("verifyBackroom") {
|| !chest.contains("REFRESH_TICKS = 12_000L") || !chest.contains("ordinal == 3")
|| !architecture.contains("PLAYABLE_RADIUS = 2_000")
|| !architecture.contains("BackroomMaterialApi.anomalyMaterial()")
|| !architecture.contains("placeLootLandmark")
|| !layout.contains("WORLD_MIN_Y = -64") || !layout.contains("WORLD_MAX_Y_EXCLUSIVE = 384")
|| !layout.contains("LEVEL_0(295, 383)") || !layout.contains("LEVEL_4(-64, 27)")
|| !layout.contains("mazeConnected") || !layout.contains("RoomCorner")
|| !layout.contains("LOOT_CHEST_CELL_SIZE_CHUNKS = 3") || !layout.contains("lootChestChunk")
|| !layout.contains("EXIT_BED_CELL_SIZE_CHUNKS = 3")
|| !layout.contains("exitBedChunk") || !layout.contains("exitBedFloor")
|| !materialApi.contains("registerAnomalyMaterial")
|| locales.any { !it.containsKey("dimension.sanctuary.backroom")
|| !it.containsKey("message.sanctuary.backroom.bed_entry_only")
|| !it.containsKey("message.sanctuary.backroom.registry_busy")
|| !it.containsKey("message.sanctuary.backroom.mailbox_full")
|| !it.containsKey("message.sanctuary.backroom.exited_with_losses") }) {
throw new GradleException("Sanctuary alpha.132 Backroom contract is incomplete")
throw new GradleException("Sanctuary alpha.134 Backroom contract is incomplete")
}
}
}
@@ -3634,6 +3696,7 @@ tasks.named("check") {
dependsOn tasks.named("alphaIndevGeneratorSmoke")
dependsOn tasks.named("capeJoinSyncSmoke")
dependsOn tasks.named("capeSyncPayloadSmoke")
dependsOn tasks.named("chickenArmorSmoke")
dependsOn tasks.named("inventorySortSmoke")
dependsOn tasks.named("entityPolicySmoke")
dependsOn tasks.named("verifyAlphaIndev")
@@ -2,6 +2,7 @@ package fr.koka99cab.sanctuary26.sanctuary;
import fr.koka99cab.sanctuary26.sanctuary.api.atlas.SanctuaryAtlasLandmarks;
import fr.koka99cab.sanctuary26.sanctuary.api.server.SanctuaryServerIdentity;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.SanctuaryRankingApi;
import fr.koka99cab.sanctuary26.sanctuary.network.SanctuaryNetworking;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryBulkActions;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryInventoryCapacity;
@@ -35,7 +36,7 @@ import org.slf4j.LoggerFactory;
public final class SanctuaryMod implements ModInitializer {
public static final String MOD_ID = "sanctuary";
public static final String DISPLAY_NAME = "Sanctuary";
public static final int CURRENT_DATA_VERSION = 14;
public static final int CURRENT_DATA_VERSION = 15;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public SanctuaryMod() {
@@ -48,6 +49,7 @@ public final class SanctuaryMod implements ModInitializer {
@Override
public void onInitialize() {
SanctuaryServerIdentity.register();
SanctuaryRankingApi.initialize();
SanctuaryDimensions.initialize();
SanctuaryDensityFunctions.register();
SanctuaryFeatures.register();
@@ -0,0 +1,22 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import java.util.Objects;
import net.minecraft.resources.Identifier;
/** Stable public description registered by gameplay modules, independent from SanctuaryStat ranks. */
public record RankingMetric(Identifier id, String translationKey, RankingUnit unit) {
public static final int MAX_IDENTIFIER_LENGTH = 256;
public static final int MAX_TRANSLATION_KEY_LENGTH = 128;
public RankingMetric {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(unit, "unit");
if (id.toString().length() > MAX_IDENTIFIER_LENGTH) {
throw new IllegalArgumentException("Ranking identifier must contain at most 256 characters");
}
translationKey = translationKey == null ? "" : translationKey.trim();
if (translationKey.isEmpty() || translationKey.length() > MAX_TRANSLATION_KEY_LENGTH) {
throw new IllegalArgumentException("Ranking translation key must contain 1-128 characters");
}
}
}
@@ -0,0 +1,44 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import java.util.List;
import java.util.UUID;
import net.minecraft.resources.Identifier;
/** Server snapshot intentionally bounded to one visible metric and three podium entries. */
public record RankingSnapshot(RankingMetric metric, List<Entry> podium, Placement owner) {
public static final int MAX_PODIUM_SIZE = 3;
public RankingSnapshot {
podium = podium == null ? List.of() : List.copyOf(podium.stream().limit(MAX_PODIUM_SIZE).toList());
owner = owner == null ? Placement.unranked() : owner;
}
public static RankingSnapshot empty(RankingMetric metric) {
return new RankingSnapshot(metric, List.of(), Placement.unranked());
}
public record Entry(UUID playerId, String playerName, long value, int rank) {
public Entry {
playerName = sanitizeName(playerName);
value = Math.max(0L, value);
rank = Math.max(0, rank);
}
}
public record Placement(long value, int rank) {
public Placement {
value = Math.max(0L, value);
rank = Math.max(0, rank);
}
public static Placement unranked() {
return new Placement(0L, 0);
}
}
private static String sanitizeName(String value) {
if (value == null || value.isBlank()) return "?";
String clean = value.strip();
return clean.length() > 32 ? clean.substring(0, 32) : clean;
}
}
@@ -0,0 +1,49 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
/** Public, bounded display formats accepted by server ranking metrics. */
public enum RankingUnit {
COUNT("count"),
TICKS("ticks"),
CENTIMETERS("centimeters");
private final String key;
RankingUnit(String key) {
this.key = key;
}
public String key() {
return key;
}
public static RankingUnit byKey(String key) {
for (RankingUnit unit : values()) {
if (unit.key.equals(key)) return unit;
}
return COUNT;
}
/** Compact, locale-neutral representation suitable for signs and chat snapshots. */
public String format(long rawValue) {
long value = Math.max(0L, rawValue);
return switch (this) {
case TICKS -> formatDuration(value / 20L);
case CENTIMETERS -> value >= 100_000L ? (value / 100_000L) + " km" : (value / 100L) + " m";
case COUNT -> compact(value);
};
}
private static String formatDuration(long seconds) {
long hours = seconds / 3_600L;
long minutes = seconds % 3_600L / 60L;
if (hours > 0L) return hours + " h " + minutes + " min";
return minutes + " min";
}
private static String compact(long value) {
if (value >= 1_000_000_000L) return value / 1_000_000_000L + "G";
if (value >= 1_000_000L) return value / 1_000_000L + "M";
if (value >= 1_000L) return value / 1_000L + "K";
return Long.toString(value);
}
}
@@ -0,0 +1,102 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
/**
* Public server-authoritative ranking API. Gameplay modules may register stable metric descriptions and
* increment them for real survival/adventure players; Red-Stoner only consumes bounded snapshots.
*/
public final class SanctuaryRankingApi {
private static final Map<Identifier, RankingMetric> METRICS = new ConcurrentHashMap<>();
private static final RankingMetric DEFAULT_METRIC = VanillaRankingMetric.descriptor(
VanillaRankingMetric.playTimeId());
private static boolean initialized;
private SanctuaryRankingApi() {}
public static void initialize() {
if (initialized) return;
initialized = true;
register(DEFAULT_METRIC);
ServerLifecycleEvents.SERVER_STARTED.register(server ->
SanctuaryRankingData.get(server).importVanillaStats(server));
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
SanctuaryRankingData data = SanctuaryRankingData.get(server);
data.updateName(handler.player.getUUID(), handler.player.getGameProfile().name());
synchronizeObserved(server, METRICS.keySet());
});
}
public static RankingMetric register(RankingMetric metric) {
RankingMetric previous = METRICS.putIfAbsent(metric.id(), metric);
if (previous != null && !previous.equals(metric)) {
throw new IllegalStateException("Ranking metric already registered with another contract: " + metric.id());
}
return previous == null ? metric : previous;
}
public static RankingMetric defaultMetric() {
return DEFAULT_METRIC;
}
public static Optional<RankingMetric> metric(Identifier id) {
RankingMetric registered = METRICS.get(id);
if (registered != null) return Optional.of(registered);
return VanillaRankingMetric.parse(id).map(source -> {
RankingMetric descriptor = VanillaRankingMetric.descriptor(id);
METRICS.putIfAbsent(id, descriptor);
return descriptor;
});
}
public static List<RankingMetric> itemMetrics(ItemStack icon) {
List<RankingMetric> metrics = VanillaRankingMetric.itemMetrics(icon);
metrics.forEach(SanctuaryRankingApi::register);
return metrics;
}
public static boolean eligible(ServerPlayer player) {
return isRealOnlinePlayer(player) && !player.isCreative() && !player.isSpectator();
}
public static void increment(ServerPlayer player, Identifier metricId, long amount) {
if (!eligible(player) || amount <= 0L || metric(metricId).isEmpty()) return;
SanctuaryRankingData.get(player.level().getServer()).increment(player.getUUID(),
player.getGameProfile().name(), metricId, amount);
}
public static RankingSnapshot snapshot(MinecraftServer server, Identifier metricId, UUID ownerId) {
RankingMetric metric = metric(metricId).orElse(DEFAULT_METRIC);
synchronizeObserved(server, List.of(metric.id()));
return SanctuaryRankingData.get(server).snapshot(metric, ownerId);
}
public static void synchronizeObserved(MinecraftServer server, Collection<Identifier> metricIds) {
if (server == null || metricIds == null || metricIds.isEmpty()) return;
SanctuaryRankingData data = SanctuaryRankingData.get(server);
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
for (Identifier metricId : metricIds) {
VanillaRankingMetric.Source source = VanillaRankingMetric.parse(metricId).orElse(null);
if (source == null) continue;
data.observeVanilla(player.getUUID(), player.getGameProfile().name(), metricId,
VanillaRankingMetric.read(player, source), eligible(player));
}
}
}
private static boolean isRealOnlinePlayer(ServerPlayer player) {
return player != null && player.getClass() == ServerPlayer.class
&& player.level().getServer().getPlayerList().getPlayer(player.getUUID()) == player;
}
}
@@ -0,0 +1,292 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Stream;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
import net.minecraft.world.level.storage.LevelResource;
/** Versioned UUID/metric index; Red-Stoner never owns or duplicates these scores. */
final class SanctuaryRankingData extends SavedData {
static final String SAVE_KEY = "rankings";
static final int DATA_VERSION = 1;
static final long MAX_VALUE = Long.MAX_VALUE / 4L;
private static final int MAX_METRICS_PER_PLAYER = 16_384;
private static final Codec<Score> SCORE_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.LONG.fieldOf("value").forGetter(Score::value),
Codec.LONG.fieldOf("reached_order").forGetter(Score::reachedOrder)
).apply(instance, Score::new));
private static final Codec<Profile> PROFILE_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.optionalFieldOf("last_name", "?").forGetter(Profile::lastName),
Codec.unboundedMap(Codec.STRING, SCORE_CODEC).optionalFieldOf("metrics", Map.of())
.forGetter(Profile::metrics),
Codec.unboundedMap(Codec.STRING, Codec.LONG).optionalFieldOf("vanilla_observed", Map.of())
.forGetter(Profile::vanillaObserved)
).apply(instance, Profile::new));
private static final Codec<SanctuaryRankingData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.INT.optionalFieldOf("data_version", DATA_VERSION).forGetter(data -> data.dataVersion),
Codec.BOOL.optionalFieldOf("vanilla_stats_imported", false).forGetter(data -> data.vanillaStatsImported),
Codec.LONG.optionalFieldOf("next_reached_order", 1L).forGetter(data -> data.nextReachedOrder),
Codec.unboundedMap(Codec.STRING, PROFILE_CODEC).optionalFieldOf("players", Map.of())
.forGetter(data -> data.players)
).apply(instance, SanctuaryRankingData::new));
private static final SavedDataType<SanctuaryRankingData> TYPE = new SavedDataType<>(
SanctuaryMod.id(SAVE_KEY), SanctuaryRankingData::new, CODEC, null);
private int dataVersion;
private boolean vanillaStatsImported;
private long nextReachedOrder;
private final Map<String, Profile> players;
SanctuaryRankingData() {
this(DATA_VERSION, false, 1L, Map.of());
}
SanctuaryRankingData(int dataVersion, boolean vanillaStatsImported, long nextReachedOrder,
Map<String, Profile> players) {
this.dataVersion = dataVersion;
this.vanillaStatsImported = vanillaStatsImported;
this.nextReachedOrder = Math.max(1L, nextReachedOrder);
this.players = new HashMap<>();
players.forEach((player, profile) -> {
try {
UUID.fromString(player);
this.players.put(player, sanitize(profile));
} catch (IllegalArgumentException ignored) {
// A malformed external key is ignored rather than poisoning the complete save.
}
});
}
static SanctuaryRankingData get(MinecraftServer server) {
ServerLevel overworld = server.getLevel(Level.OVERWORLD);
if (overworld == null) throw new IllegalStateException("Overworld unavailable for ranking data");
SanctuaryRankingData data = overworld.getDataStorage().computeIfAbsent(TYPE);
if (data.dataVersion < DATA_VERSION) {
data.dataVersion = DATA_VERSION;
data.setDirty();
} else if (data.dataVersion > DATA_VERSION) {
SanctuaryMod.LOGGER.warn("[{}] Ranking data v{} is newer than supported v{}; writes are disabled.",
SanctuaryMod.DISPLAY_NAME, data.dataVersion, DATA_VERSION);
}
return data;
}
boolean writable() {
return dataVersion <= DATA_VERSION;
}
void updateName(UUID playerId, String name) {
if (!writable()) return;
String key = playerId.toString();
Profile current = players.getOrDefault(key, new Profile(cleanName(name), Map.of(), Map.of()));
String clean = cleanName(name);
if (current.lastName().equals(clean)) return;
players.put(key, new Profile(clean, current.metrics(), current.vanillaObserved()));
setDirty();
}
void recordValue(UUID playerId, String name, Identifier metricId, long rawValue) {
if (!writable() || rawValue < 0L) return;
String key = playerId.toString();
Profile current = players.getOrDefault(key, new Profile(cleanName(name), Map.of(), Map.of()));
Map<String, Score> metrics = new HashMap<>(current.metrics());
String metricKey = metricId.toString();
Score previous = metrics.get(metricKey);
long value = Math.min(MAX_VALUE, rawValue);
if (previous != null && previous.value() >= value) {
updateName(playerId, name);
return;
}
if (previous == null && metrics.size() >= MAX_METRICS_PER_PLAYER) return;
metrics.put(metricKey, new Score(value, takeReachedOrder()));
players.put(key, new Profile(cleanName(name), metrics, current.vanillaObserved()));
setDirty();
}
void observeVanilla(UUID playerId, String name, Identifier metricId, long rawValue, boolean credited) {
if (!writable() || rawValue < 0L) return;
String key = playerId.toString();
Profile current = players.getOrDefault(key, new Profile(cleanName(name), Map.of(), Map.of()));
Map<String, Long> observed = new HashMap<>(current.vanillaObserved());
String metricKey = metricId.toString();
Long previous = observed.get(metricKey);
if (previous == null && observed.size() >= MAX_METRICS_PER_PLAYER) return;
long value = Math.min(MAX_VALUE, rawValue);
if (previous == null || previous.longValue() != value || !current.lastName().equals(cleanName(name))) {
observed.put(metricKey, value);
players.put(key, new Profile(cleanName(name), current.metrics(), observed));
setDirty();
}
long creditedDelta = previous == null ? value : Math.max(0L, value - previous);
if (credited && creditedDelta > 0L) {
increment(playerId, name, metricId, creditedDelta);
}
}
void increment(UUID playerId, String name, Identifier metricId, long amount) {
if (!writable() || amount <= 0L) return;
long current = score(playerId, metricId);
long value = current >= MAX_VALUE - amount ? MAX_VALUE : current + amount;
recordValue(playerId, name, metricId, value);
}
long score(UUID playerId, Identifier metricId) {
Profile profile = players.get(playerId.toString());
Score score = profile == null ? null : profile.metrics().get(metricId.toString());
return score == null ? 0L : score.value();
}
RankingSnapshot snapshot(RankingMetric metric, UUID ownerId) {
List<Candidate> candidates = new ArrayList<>();
players.forEach((player, profile) -> {
Score score = profile.metrics().get(metric.id().toString());
if (score == null || score.value() <= 0L) return;
try {
candidates.add(new Candidate(UUID.fromString(player), profile.lastName(), score));
} catch (IllegalArgumentException ignored) {
// Already filtered during load, retained as defensive parsing.
}
});
candidates.sort(Comparator.comparingLong((Candidate candidate) -> candidate.score().value()).reversed()
.thenComparingLong(candidate -> candidate.score().reachedOrder())
.thenComparing(candidate -> candidate.playerId().toString()));
List<RankingSnapshot.Entry> podium = new ArrayList<>();
RankingSnapshot.Placement owner = RankingSnapshot.Placement.unranked();
long previousValue = -1L;
int rank = 0;
for (int index = 0; index < candidates.size(); index++) {
Candidate candidate = candidates.get(index);
if (candidate.score().value() != previousValue) {
rank = index + 1;
previousValue = candidate.score().value();
}
if (podium.size() < RankingSnapshot.MAX_PODIUM_SIZE) {
podium.add(new RankingSnapshot.Entry(candidate.playerId(), candidate.name(),
candidate.score().value(), rank));
}
if (candidate.playerId().equals(ownerId)) {
owner = new RankingSnapshot.Placement(candidate.score().value(), rank);
}
}
return new RankingSnapshot(metric, podium, owner);
}
void importVanillaStats(MinecraftServer server) {
if (!writable() || vanillaStatsImported) return;
Path statsDirectory = server.getWorldPath(LevelResource.PLAYER_STATS_DIR);
if (Files.isDirectory(statsDirectory)) {
try (Stream<Path> files = Files.list(statsDirectory)) {
files.filter(path -> path.getFileName().toString().endsWith(".json"))
.sorted().forEach(this::importVanillaFile);
} catch (IOException exception) {
SanctuaryMod.LOGGER.warn("[{}] Could not import existing vanilla statistics.",
SanctuaryMod.DISPLAY_NAME, exception);
return;
}
}
vanillaStatsImported = true;
setDirty();
}
void importVanillaFile(Path file) {
String fileName = file.getFileName().toString();
String idText = fileName.substring(0, fileName.length() - ".json".length());
UUID playerId;
try {
playerId = UUID.fromString(idText);
} catch (IllegalArgumentException ignored) {
return;
}
try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
JsonObject root = JsonParser.parseReader(reader).getAsJsonObject();
JsonObject stats = root.has("stats") && root.get("stats").isJsonObject()
? root.getAsJsonObject("stats") : new JsonObject();
for (Map.Entry<String, JsonElement> typeEntry : stats.entrySet()) {
Identifier type = Identifier.tryParse(typeEntry.getKey());
if (type == null || !typeEntry.getValue().isJsonObject()) continue;
for (Map.Entry<String, JsonElement> valueEntry : typeEntry.getValue().getAsJsonObject().entrySet()) {
Identifier target = Identifier.tryParse(valueEntry.getKey());
if (target == null || !valueEntry.getValue().isJsonPrimitive()
|| !valueEntry.getValue().getAsJsonPrimitive().isNumber()) continue;
Identifier metricId = VanillaRankingMetric.id(type, target);
long value = Math.max(0L, valueEntry.getValue().getAsLong());
recordValue(playerId, shortFallbackName(playerId), metricId, value);
observeVanilla(playerId, shortFallbackName(playerId), metricId, value, false);
}
}
} catch (RuntimeException | IOException exception) {
SanctuaryMod.LOGGER.warn("[{}] Ignoring unreadable vanilla statistic file {}.",
SanctuaryMod.DISPLAY_NAME, file.getFileName(), exception);
}
}
private long takeReachedOrder() {
long current = nextReachedOrder;
if (nextReachedOrder < Long.MAX_VALUE) nextReachedOrder++;
return current;
}
private static Profile sanitize(Profile profile) {
Map<String, Score> metrics = new HashMap<>();
profile.metrics().entrySet().stream().limit(MAX_METRICS_PER_PLAYER).forEach(entry -> {
if (Identifier.tryParse(entry.getKey()) != null) metrics.put(entry.getKey(), entry.getValue());
});
Map<String, Long> observed = new HashMap<>();
profile.vanillaObserved().entrySet().stream().limit(MAX_METRICS_PER_PLAYER).forEach(entry -> {
if (Identifier.tryParse(entry.getKey()) != null) {
observed.put(entry.getKey(), Math.clamp(entry.getValue(), 0L, MAX_VALUE));
}
});
return new Profile(cleanName(profile.lastName()), metrics, observed);
}
private static String cleanName(String value) {
if (value == null || value.isBlank()) return "?";
String clean = value.strip();
return clean.length() > 32 ? clean.substring(0, 32) : clean;
}
private static String shortFallbackName(UUID playerId) {
return playerId.toString().substring(0, 8);
}
record Score(long value, long reachedOrder) {
Score {
value = Math.clamp(value, 0L, MAX_VALUE);
reachedOrder = Math.max(1L, reachedOrder);
}
}
record Profile(String lastName, Map<String, Score> metrics, Map<String, Long> vanillaObserved) {
Profile {
lastName = cleanName(lastName);
metrics = metrics == null ? Map.of() : Map.copyOf(metrics);
vanillaObserved = vanillaObserved == null ? Map.of() : Map.copyOf(vanillaObserved);
}
}
private record Candidate(UUID playerId, String name, Score score) {}
}
@@ -0,0 +1,88 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.stats.Stats;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
/** Stable encoding and server reader for persisted vanilla statistic identifiers. */
final class VanillaRankingMetric {
private static final String PREFIX = "vanilla/";
private static final List<String> ITEM_FAMILIES = List.of(
"mined", "crafted", "used", "broken", "picked_up", "dropped");
private VanillaRankingMetric() {}
static Identifier id(Identifier type, Identifier target) {
return SanctuaryMod.id(PREFIX + type.getNamespace() + "/" + type.getPath() + "/"
+ target.getNamespace() + "/" + target.getPath());
}
static Identifier playTimeId() {
return id(Identifier.withDefaultNamespace("custom"), Stats.PLAY_TIME);
}
static RankingMetric descriptor(Identifier id) {
Source source = parse(id).orElse(null);
if (source == null) {
return new RankingMetric(id, "metric.sanctuary.ranking.unknown", RankingUnit.COUNT);
}
boolean playTime = source.type().getPath().equals("custom") && source.target().equals(Stats.PLAY_TIME);
RankingUnit unit = playTime ? RankingUnit.TICKS : RankingUnit.COUNT;
return new RankingMetric(id, "metric.sanctuary.ranking."
+ (playTime ? "play_time" : source.type().getPath()), unit);
}
static List<RankingMetric> itemMetrics(ItemStack icon) {
if (icon == null || icon.isEmpty()) return List.of();
Item item = icon.getItem();
Identifier itemId = BuiltInRegistries.ITEM.getKey(item);
List<RankingMetric> metrics = new ArrayList<>();
for (String family : ITEM_FAMILIES) {
if (family.equals("mined") && !(item instanceof BlockItem)) continue;
Identifier metricId = id(Identifier.withDefaultNamespace(family), itemId);
metrics.add(new RankingMetric(metricId, "metric.sanctuary.ranking." + family, RankingUnit.COUNT));
}
return List.copyOf(metrics);
}
static Optional<Source> parse(Identifier metricId) {
if (!metricId.getNamespace().equals(SanctuaryMod.MOD_ID)
|| !metricId.getPath().startsWith(PREFIX)) return Optional.empty();
String[] parts = metricId.getPath().split("/", 5);
if (parts.length != 5) return Optional.empty();
Identifier type = Identifier.tryParse(parts[1] + ":" + parts[2]);
Identifier target = Identifier.tryParse(parts[3] + ":" + parts[4]);
return type == null || target == null ? Optional.empty() : Optional.of(new Source(type, target));
}
static long read(ServerPlayer player, Source source) {
String family = source.type().getPath();
Identifier target = source.target();
return switch (family) {
case "mined" -> BuiltInRegistries.BLOCK.getOptional(target)
.map(block -> (long) player.getStats().getValue(Stats.BLOCK_MINED, block)).orElse(0L);
case "crafted" -> BuiltInRegistries.ITEM.getOptional(target)
.map(item -> (long) player.getStats().getValue(Stats.ITEM_CRAFTED, item)).orElse(0L);
case "used" -> BuiltInRegistries.ITEM.getOptional(target)
.map(item -> (long) player.getStats().getValue(Stats.ITEM_USED, item)).orElse(0L);
case "broken" -> BuiltInRegistries.ITEM.getOptional(target)
.map(item -> (long) player.getStats().getValue(Stats.ITEM_BROKEN, item)).orElse(0L);
case "picked_up" -> BuiltInRegistries.ITEM.getOptional(target)
.map(item -> (long) player.getStats().getValue(Stats.ITEM_PICKED_UP, item)).orElse(0L);
case "dropped" -> BuiltInRegistries.ITEM.getOptional(target)
.map(item -> (long) player.getStats().getValue(Stats.ITEM_DROPPED, item)).orElse(0L);
case "custom" -> (long) player.getStats().getValue(Stats.CUSTOM, target);
default -> 0L;
};
}
record Source(Identifier type, Identifier target) {}
}
@@ -30,6 +30,8 @@ import net.minecraft.world.item.equipment.Equippable;
/** Renders a banner-decorated cape with vanilla cape physics and banner pattern sprites. */
public final class SanctuaryBannerCapeLayer extends RenderLayer<AvatarRenderState, PlayerModel> {
private static final float SHIELD_PATTERN_X_SCALE = 1.2F;
private static final float SHIELD_PATTERN_Y_SCALE = 1.375F;
private static final ModelLayerLocation MODEL_LAYER =
new ModelLayerLocation(SanctuaryMod.id("banner_cape"), "main");
private final PlayerCapeModel model;
@@ -67,7 +69,7 @@ public final class SanctuaryBannerCapeLayer extends RenderLayer<AvatarRenderStat
PartDefinition body = root.getChild("body");
body.addOrReplaceChild("cape", CubeListBuilder.create().texOffs(0, 0)
.addBox(-5.0F, 0.0F, -1.0F, 10.0F, 16.0F, 1.0F,
CubeDeformation.NONE, 2.0F, 2.5F),
CubeDeformation.NONE, SHIELD_PATTERN_X_SCALE, SHIELD_PATTERN_Y_SCALE),
PartPose.offsetAndRotation(0.0F, 0.0F, 2.0F, 0.0F, (float) Math.PI, 0.0F));
return LayerDefinition.create(mesh, 64, 64);
}
@@ -85,7 +87,7 @@ public final class SanctuaryBannerCapeLayer extends RenderLayer<AvatarRenderStat
poseStack.translate(0.0F, -0.053125F, 0.06875F);
}
BannerRenderer.submitPatterns(context.getSprites(), poseStack, collector, packedLight,
OverlayTexture.NO_OVERLAY, model, state, true, appearance.baseColor(),
OverlayTexture.NO_OVERLAY, model, state, false, appearance.baseColor(),
appearance.patterns(), null);
poseStack.popPose();
}
@@ -0,0 +1,64 @@
package fr.koka99cab.sanctuary26.sanctuary.client;
import com.mojang.blaze3d.vertex.PoseStack;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import net.fabricmc.fabric.api.client.rendering.v1.LivingEntityRenderLayerRegistrationCallback;
import net.fabricmc.fabric.api.client.rendering.v1.ModelLayerRegistry;
import net.minecraft.client.model.animal.chicken.ChickenModel;
import net.minecraft.client.model.geom.ModelLayerLocation;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.entity.ChickenRenderer;
import net.minecraft.client.renderer.entity.EntityRendererProvider;
import net.minecraft.client.renderer.entity.RenderLayerParent;
import net.minecraft.client.renderer.entity.layers.EquipmentLayerRenderer;
import net.minecraft.client.renderer.entity.layers.RenderLayer;
import net.minecraft.client.renderer.entity.state.ChickenRenderState;
import net.minecraft.client.resources.model.EquipmentClientInfo;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.equipment.Equippable;
/** Renders vanilla armadillo-scute equipment around an adult chicken's torso. */
public final class SanctuaryChickenArmorLayer extends RenderLayer<ChickenRenderState, ChickenModel> {
private static final ModelLayerLocation MODEL_LAYER =
new ModelLayerLocation(SanctuaryMod.id("chicken_armor"), "main");
private final SanctuaryChickenArmorModel model;
private final EquipmentLayerRenderer equipmentRenderer;
private SanctuaryChickenArmorLayer(RenderLayerParent<ChickenRenderState, ChickenModel> parent,
EntityRendererProvider.Context context) {
super(parent);
model = SanctuaryChickenArmorModel.bake(context.bakeLayer(MODEL_LAYER));
equipmentRenderer = context.getEquipmentRenderer();
}
public static void initialize() {
ModelLayerRegistry.registerModelLayer(MODEL_LAYER, SanctuaryChickenArmorModel::createLayer);
LivingEntityRenderLayerRegistrationCallback.EVENT.register((entityType, renderer, helper, context) -> {
if (entityType == EntityTypes.CHICKEN && renderer instanceof ChickenRenderer chickenRenderer) {
register(helper, chickenRenderer, context);
}
});
}
@SuppressWarnings({"unchecked", "rawtypes"})
private static void register(LivingEntityRenderLayerRegistrationCallback.RegistrationHelper helper,
ChickenRenderer renderer, EntityRendererProvider.Context context) {
RenderLayerParent<ChickenRenderState, ChickenModel> parent =
(RenderLayerParent<ChickenRenderState, ChickenModel>) (RenderLayerParent) renderer;
helper.register(new SanctuaryChickenArmorLayer(parent, context));
}
@Override
public void submit(PoseStack poseStack, SubmitNodeCollector collector, int packedLight,
ChickenRenderState state, float yRot, float xRot) {
if (state.isBaby || state.isInvisibleToPlayer) return;
ItemStack armor = ((SanctuaryChickenArmorRenderState) state).sanctuary$chickenArmor();
Equippable equippable = armor.get(DataComponents.EQUIPPABLE);
if (equippable == null || equippable.assetId().isEmpty()) return;
equipmentRenderer.renderLayers(EquipmentClientInfo.LayerType.WOLF_BODY,
equippable.assetId().get(), model, state, armor, poseStack, collector,
packedLight, state.outlineColor);
}
}
@@ -0,0 +1,32 @@
package fr.koka99cab.sanctuary26.sanctuary.client;
import net.minecraft.client.model.EntityModel;
import net.minecraft.client.model.geom.ModelPart;
import net.minecraft.client.model.geom.PartPose;
import net.minecraft.client.model.geom.builders.CubeDeformation;
import net.minecraft.client.model.geom.builders.CubeListBuilder;
import net.minecraft.client.model.geom.builders.LayerDefinition;
import net.minecraft.client.model.geom.builders.MeshDefinition;
import net.minecraft.client.renderer.entity.state.ChickenRenderState;
/** A thin torso shell using the vanilla armadillo-scute equipment UVs. */
public final class SanctuaryChickenArmorModel extends EntityModel<ChickenRenderState> {
private SanctuaryChickenArmorModel(ModelPart root) {
super(root);
}
public static SanctuaryChickenArmorModel bake(ModelPart root) {
return new SanctuaryChickenArmorModel(root);
}
public static LayerDefinition createLayer() {
MeshDefinition mesh = new MeshDefinition();
mesh.getRoot().addOrReplaceChild("body",
CubeListBuilder.create().texOffs(18, 14)
.addBox(-3.0F, -4.0F, -3.0F, 6.0F, 8.0F, 6.0F,
new CubeDeformation(0.45F)),
PartPose.offsetAndRotation(0.0F, 16.0F, 0.0F,
(float) (Math.PI / 2.0D), 0.0F, 0.0F));
return LayerDefinition.create(mesh, 64, 32);
}
}
@@ -0,0 +1,10 @@
package fr.koka99cab.sanctuary26.sanctuary.client;
import net.minecraft.world.item.ItemStack;
/** Mixin-backed bridge carrying chicken body armor into the extracted render state. */
public interface SanctuaryChickenArmorRenderState {
ItemStack sanctuary$chickenArmor();
void sanctuary$setChickenArmor(ItemStack armor);
}
@@ -120,6 +120,7 @@ public final class SanctuaryClient implements ClientModInitializer {
SanctuaryHealthPreview.register();
CelestialSpyglassSelection.register();
SanctuaryBannerCapeLayer.initialize();
SanctuaryChickenArmorLayer.initialize();
EntityRendererRegistry.register(SanctuaryRegistries.SEAT, NoopRenderer::new);
ClientTickEvents.END_CLIENT_TICK.register(SanctuaryClient::tick);
ClientPlayNetworking.registerGlobalReceiver(ProgressionSnapshotPayload.ID, (payload, context) ->
@@ -0,0 +1,25 @@
package fr.koka99cab.sanctuary26.sanctuary.gameplay;
import net.minecraft.tags.DamageTypeTags;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Shared server rules for fitting vanilla armadillo-scute armor to adult chickens. */
public final class SanctuaryChickenArmor {
private SanctuaryChickenArmor() {
}
public static boolean canEquip(ItemStack equipped, ItemStack held, boolean baby) {
return !baby && equipped.isEmpty() && held.is(Items.WOLF_ARMOR);
}
public static boolean canRepair(ItemStack equipped, ItemStack held) {
return equipped.is(Items.WOLF_ARMOR) && equipped.isDamaged()
&& equipped.isValidRepairItem(held);
}
public static boolean absorbs(ItemStack equipped, DamageSource source) {
return equipped.is(Items.WOLF_ARMOR) && !source.is(DamageTypeTags.BYPASSES_WOLF_ARMOR);
}
}
@@ -0,0 +1,43 @@
package fr.koka99cab.sanctuary26.sanctuary.mixin;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryChickenArmor;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.util.Mth;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.animal.Animal;
import net.minecraft.world.entity.animal.chicken.Chicken;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** Lets adult chickens wear and repair the vanilla armadillo-scute wolf armor. */
@Mixin(Animal.class)
abstract class AnimalChickenArmorMixin {
@Inject(method = "mobInteract", at = @At("HEAD"), cancellable = true)
private void sanctuary$handleChickenArmor(Player player, InteractionHand hand,
CallbackInfoReturnable<InteractionResult> cir) {
if (!((Object) this instanceof Chicken chicken)) return;
ItemStack held = player.getItemInHand(hand);
ItemStack equipped = chicken.getItemBySlot(EquipmentSlot.BODY);
if (SanctuaryChickenArmor.canEquip(equipped, held, chicken.isBaby())) {
chicken.setItemSlot(EquipmentSlot.BODY, held.copyWithCount(1));
chicken.setGuaranteedDrop(EquipmentSlot.BODY);
held.consume(1, player);
chicken.playSound(SoundEvents.ARMOR_EQUIP_WOLF.value());
cir.setReturnValue(InteractionResult.SUCCESS);
return;
}
if (SanctuaryChickenArmor.canRepair(equipped, held)) {
held.consume(1, player);
int repairUnit = Mth.ceil(equipped.getMaxDamage() * 0.125F);
equipped.setDamageValue(Math.max(0, equipped.getDamageValue() - repairUnit));
chicken.playSound(SoundEvents.WOLF_ARMOR_REPAIR);
cir.setReturnValue(InteractionResult.SUCCESS);
}
}
}
@@ -2,9 +2,11 @@ package fr.koka99cab.sanctuary26.sanctuary.mixin;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryCompanionPowers;
import java.util.function.Consumer;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.SpawnEggItem;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
@@ -21,5 +23,9 @@ abstract class ItemTooltipMixin {
if (stack.getItem() instanceof SpawnEggItem) {
SanctuaryCompanionPowers.appendPowerTooltip(stack, tooltip);
}
if (stack.is(Items.WOLF_ARMOR)) {
tooltip.accept(Component.translatable("tooltip.sanctuary.chicken_armor")
.withStyle(ChatFormatting.GRAY));
}
}
}
@@ -0,0 +1,44 @@
package fr.koka99cab.sanctuary26.sanctuary.mixin;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryChickenArmor;
import net.minecraft.core.particles.ItemParticleOption;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.util.Mth;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.entity.Crackiness;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.animal.chicken.Chicken;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Gives armored chickens the same damage-absorption contract as armored wolves. */
@Mixin(LivingEntity.class)
abstract class LivingEntityChickenArmorMixin {
@Inject(method = "actuallyHurt", at = @At("HEAD"), cancellable = true)
private void sanctuary$absorbChickenDamage(ServerLevel level, DamageSource source,
float damage, CallbackInfo ci) {
if (!((Object) this instanceof Chicken chicken)) return;
ItemStack armor = chicken.getItemBySlot(EquipmentSlot.BODY);
if (!SanctuaryChickenArmor.absorbs(armor, source)) return;
int damageBefore = armor.getDamageValue();
int maxDamage = armor.getMaxDamage();
armor.hurtAndBreak(Mth.ceil(damage), chicken, EquipmentSlot.BODY);
chicken.playSound(SoundEvents.WOLF_ARMOR_DAMAGE);
if (Crackiness.WOLF_ARMOR.byDamage(damageBefore, maxDamage)
!= Crackiness.WOLF_ARMOR.byDamage(chicken.getItemBySlot(EquipmentSlot.BODY))) {
chicken.playSound(SoundEvents.WOLF_ARMOR_CRACK);
level.sendParticles(new ItemParticleOption(ParticleTypes.ITEM,
Items.ARMADILLO_SCUTE), chicken.getX(), chicken.getY() + 0.5D,
chicken.getZ(), 12, 0.2D, 0.15D, 0.2D, 0.08D);
}
ci.cancel();
}
}
@@ -0,0 +1,23 @@
package fr.koka99cab.sanctuary26.sanctuary.mixin.client;
import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryChickenArmorRenderState;
import net.minecraft.client.renderer.entity.state.ChickenRenderState;
import net.minecraft.world.item.ItemStack;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
/** Stores synchronized chicken body armor on the client render state. */
@Mixin(ChickenRenderState.class)
abstract class ChickenRenderStateMixin implements SanctuaryChickenArmorRenderState {
@Unique private ItemStack sanctuary$chickenArmor = ItemStack.EMPTY;
@Override
public ItemStack sanctuary$chickenArmor() {
return sanctuary$chickenArmor;
}
@Override
public void sanctuary$setChickenArmor(ItemStack armor) {
sanctuary$chickenArmor = armor == null ? ItemStack.EMPTY : armor;
}
}
@@ -0,0 +1,22 @@
package fr.koka99cab.sanctuary26.sanctuary.mixin.client;
import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryChickenArmorRenderState;
import net.minecraft.client.renderer.entity.ChickenRenderer;
import net.minecraft.client.renderer.entity.state.ChickenRenderState;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.animal.chicken.Chicken;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Copies vanilla-synchronized body equipment into each chicken render state. */
@Mixin(ChickenRenderer.class)
abstract class ChickenRendererMixin {
@Inject(method = "extractRenderState", at = @At("TAIL"))
private void sanctuary$extractChickenArmor(Chicken chicken, ChickenRenderState state,
float partialTick, CallbackInfo ci) {
((SanctuaryChickenArmorRenderState) state).sanctuary$setChickenArmor(
chicken.getItemBySlot(EquipmentSlot.BODY).copy());
}
}
@@ -425,8 +425,11 @@ public final class QuestRequestService {
"message.sanctuary.quest_request.details.reward_legacy").withStyle(ChatFormatting.GRAY));
QuestRequestData.Claim claim = request.claim().orElse(null);
if (request.completed()) {
player.sendSystemMessage(Component.translatable("message.sanctuary.quest_request.details.completed")
.withStyle(ChatFormatting.GREEN));
Component completion = claim == null
? Component.translatable("message.sanctuary.quest_request.details.completed")
: Component.translatable("message.sanctuary.quest_request.details.completed_by",
claimantName(claim));
player.sendSystemMessage(completion.copy().withStyle(ChatFormatting.GREEN));
} else if (claim != null) {
Component claimant = Component.literal(claim.playerName())
.withStyle(style -> style.withColor(claim.playerColor()));
@@ -444,6 +447,11 @@ public final class QuestRequestService {
return Component.translatable("message.sanctuary.quest_request.action." + action);
}
private static Component claimantName(QuestRequestData.Claim claim) {
return Component.literal(claim.playerName())
.withStyle(style -> style.withColor(claim.playerColor()));
}
private static Component describeTarget(QuestRequestData.Request request) {
if (PAY.equals(request.action()) || PLAYER.equals(request.targetKind())) {
return Component.literal(request.target());
@@ -485,13 +493,20 @@ public final class QuestRequestService {
if (request.completed()) {
text = text.setMessage(2, Component.translatable("sign.sanctuary.quest_request.completed")
.withStyle(ChatFormatting.DARK_GRAY));
QuestRequestData.Claim claim = request.claim().orElse(null);
text = text.setMessage(3, claim == null
? Component.translatable("sign.sanctuary.quest_request.details")
.withStyle(ChatFormatting.GRAY)
: Component.translatable("sign.sanctuary.quest_request.completed_by", claimantName(claim))
.withStyle(ChatFormatting.GRAY));
} else if (request.claim().isPresent()) {
text = text.setMessage(2, Component.translatable("sign.sanctuary.quest_request.claimed")
.withStyle(ChatFormatting.DARK_GRAY));
} else text = text.setMessage(2, Component.translatable("sign.sanctuary.quest_request.available")
.withStyle(ChatFormatting.DARK_GRAY));
text = text.setMessage(3, Component.translatable("sign.sanctuary.quest_request.details")
.withStyle(ChatFormatting.GRAY));
if (!request.completed()) text = text.setMessage(3,
Component.translatable("sign.sanctuary.quest_request.details")
.withStyle(ChatFormatting.GRAY));
}
sign.setText(text, request.front());
sign.setWaxed(true);
@@ -30,7 +30,6 @@ public final class BackroomArchitectureFeature extends Feature<NoneFeatureConfig
private static final int[] THRESHOLD_DECKS = {295, 313, 331, 349, 367};
private static final int[] ARCHIVE_DECKS = {206, 228, 250, 272};
private static final int[] UTILITY_DECKS = {28, 46, 64, 82, 100};
private static final int[] LANDMARK_FLOORS = {349, 250, 117, 64, BackroomLayout.MAZE_FLOOR_Y};
private static final BlockState CONCRETE_GRAY = Blocks.CONCRETE.pick(DyeColor.GRAY).defaultBlockState();
private static final BlockState CONCRETE_LIGHT = Blocks.CONCRETE.pick(DyeColor.LIGHT_GRAY).defaultBlockState();
@@ -78,7 +77,7 @@ public final class BackroomArchitectureFeature extends Feature<NoneFeatureConfig
buildCity(level, worldSeed, startX, startZ, chunkX, chunkZ);
buildUtilities(level, worldSeed, startX, startZ);
buildGlitchMaze(level, worldSeed, startX, startZ);
placeLootLandmarks(level, startX, startZ, random);
placeLootLandmarks(level, worldSeed, startX, startZ, chunkX, chunkZ);
placeRareAnomaly(level, startX, startZ, random);
return true;
}
@@ -416,19 +415,19 @@ public final class BackroomArchitectureFeature extends Feature<NoneFeatureConfig
return coordinate >= minimum && coordinate <= maximum;
}
private static void placeLootLandmarks(WorldGenLevel level, int x0, int z0, Random random) {
if (random.nextInt(5) == 0) {
int floorY = LANDMARK_FLOORS[random.nextInt(LANDMARK_FLOORS.length)];
int x = x0 + 7, y = floorY + 1, z = z0 + 7;
set(level, x, floorY, z, floorMaterial(BackroomLayout.levelAt(floorY)));
set(level, x, y, z, Blocks.CHEST.defaultBlockState());
set(level, x, y + 1, z, Blocks.AIR.defaultBlockState());
private static void placeLootLandmarks(WorldGenLevel level, long seed, int x0, int z0,
int chunkX, int chunkZ) {
for (BackroomLevel zone : BackroomLevel.values()) {
if (BackroomLayout.lootChestChunk(seed, zone, chunkX, chunkZ)) {
placeLootLandmark(level, zone, x0 + 7, z0 + 7);
}
}
if (random.nextInt(96) == 0) {
int floorY = 272;
for (BackroomLevel zone : BackroomLevel.values()) {
if (!BackroomLayout.exitBedChunk(seed, zone, chunkX, chunkZ)) continue;
int floorY = BackroomLayout.exitBedFloor(zone);
int x = x0 + 7, z = z0 + 8;
set(level, x, floorY, z, Blocks.DARK_OAK_PLANKS.defaultBlockState());
set(level, x + 1, floorY, z, Blocks.DARK_OAK_PLANKS.defaultBlockState());
set(level, x, floorY, z, floorMaterial(zone));
set(level, x + 1, floorY, z, floorMaterial(zone));
for (int dx = 0; dx <= 1; dx++) for (int dy = 1; dy <= 2; dy++) {
set(level, x + dx, floorY + dy, z, Blocks.AIR.defaultBlockState());
}
@@ -436,10 +435,21 @@ public final class BackroomArchitectureFeature extends Feature<NoneFeatureConfig
}
}
private static void placeLootLandmark(WorldGenLevel level, BackroomLevel zone, int x, int z) {
int floorY = BackroomLayout.exitBedFloor(zone);
for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++) {
set(level, x + dx, floorY, z + dz, floorMaterial(zone));
for (int dy = 1; dy <= 2; dy++) set(level, x + dx, floorY + dy, z + dz,
Blocks.AIR.defaultBlockState());
}
set(level, x, floorY + 1, z, Blocks.CHEST.defaultBlockState());
}
private static void placeRareAnomaly(WorldGenLevel level, int x0, int z0, Random random) {
if (random.nextInt(96) == 0) {
Block anomaly = BackroomMaterialApi.anomalyMaterial();
int floorY = LANDMARK_FLOORS[random.nextInt(LANDMARK_FLOORS.length)];
BackroomLevel zone = BackroomLevel.values()[random.nextInt(BackroomLevel.values().length)];
int floorY = BackroomLayout.exitBedFloor(zone);
for (int index = 0; index < 5; index++) {
set(level, x0 + 5 + random.nextInt(6), floorY + 1 + random.nextInt(4),
z0 + 5 + random.nextInt(6), anomaly.defaultBlockState());
@@ -13,6 +13,8 @@ public final class BackroomLayout {
public static final int ROOM_SIZE = 16;
public static final int MAZE_FLOOR_Y = 8;
public static final int MAZE_ROOF_Y = 18;
public static final int EXIT_BED_CELL_SIZE_CHUNKS = 3;
public static final int LOOT_CHEST_CELL_SIZE_CHUNKS = 3;
private static final int HOLE_CELL_SIZE = 64;
private BackroomLayout() {}
@@ -90,6 +92,45 @@ public final class BackroomLayout {
return Math.floorMod((int) mix(seed ^ 0x43c2a1769f0e85bdL, chunkX, chunkZ), 11) == 0;
}
/** One easy-to-find loot landmark per three-by-three chunk cell and per vertical level. */
public static boolean lootChestChunk(long seed, BackroomLevel level, int chunkX, int chunkZ) {
Objects.requireNonNull(level, "level");
int cellX = Math.floorDiv(chunkX, LOOT_CHEST_CELL_SIZE_CHUNKS);
int cellZ = Math.floorDiv(chunkZ, LOOT_CHEST_CELL_SIZE_CHUNKS);
long hash = mix(seed ^ 0x354c6f3a82d9e107L ^ (long) level.ordinal() * 0x9e3779b97f4a7c15L,
cellX, cellZ);
int targetX = cellX * LOOT_CHEST_CELL_SIZE_CHUNKS
+ Math.floorMod((int) hash, LOOT_CHEST_CELL_SIZE_CHUNKS);
int targetZ = cellZ * LOOT_CHEST_CELL_SIZE_CHUNKS
+ Math.floorMod((int) (hash >>> 32), LOOT_CHEST_CELL_SIZE_CHUNKS);
return chunkX == targetX && chunkZ == targetZ;
}
/** One deterministic exit-bed chunk per three-by-three grid cell and per vertical level. */
public static boolean exitBedChunk(long seed, BackroomLevel level, int chunkX, int chunkZ) {
Objects.requireNonNull(level, "level");
int cellX = Math.floorDiv(chunkX, EXIT_BED_CELL_SIZE_CHUNKS);
int cellZ = Math.floorDiv(chunkZ, EXIT_BED_CELL_SIZE_CHUNKS);
long hash = mix(seed ^ 0x6ac690c5d07f12e9L ^ (long) level.ordinal() * 0x9e3779b97f4a7c15L,
cellX, cellZ);
int targetX = cellX * EXIT_BED_CELL_SIZE_CHUNKS
+ Math.floorMod((int) hash, EXIT_BED_CELL_SIZE_CHUNKS);
int targetZ = cellZ * EXIT_BED_CELL_SIZE_CHUNKS
+ Math.floorMod((int) (hash >>> 32), EXIT_BED_CELL_SIZE_CHUNKS);
return chunkX == targetX && chunkZ == targetZ;
}
/** A traversable authored floor used for exit beds in each of the five levels. */
public static int exitBedFloor(BackroomLevel level) {
return switch (Objects.requireNonNull(level, "level")) {
case LEVEL_0 -> 349;
case LEVEL_1 -> 250;
case LEVEL_2 -> 117;
case LEVEL_3 -> 64;
case LEVEL_4 -> MAZE_FLOOR_Y;
};
}
private static boolean isParent(int childX, int childZ, int parentX, int parentZ) {
if (childX == 0 && childZ == 0) return false;
int expectedX = childX, expectedZ = childZ;
@@ -96,6 +96,7 @@
"message.sanctuary.quest_request.details.reward_legacy": "Its author must edit this legacy request and deposit the reward.",
"message.sanctuary.quest_request.details.progress": "Progress: %s/%s.",
"message.sanctuary.quest_request.details.completed": "Request completed · reward dispatched.",
"message.sanctuary.quest_request.details.completed_by": "Request completed by %s · reward dispatched.",
"message.sanctuary.quest_request.accept": "[ACCEPT QUEST]",
"message.sanctuary.quest_request.accept_hover": "Click to confirm after reading every detail.",
"message.sanctuary.quest_request.accept_prompt": "Read the details, then confirm in the acceptance screen.",
@@ -462,6 +463,7 @@
"sign.sanctuary.quest_request.available": "AVAILABLE",
"sign.sanctuary.quest_request.claimed": "TAKEN",
"sign.sanctuary.quest_request.completed": "COMPLETED",
"sign.sanctuary.quest_request.completed_by": "Completed by: %s",
"sign.sanctuary.quest_request.details": "Click: details",
"message.sanctuary.companion.active_locked": "Unlock the active companion power in Progression for %s XP levels.",
"message.sanctuary.shop.delivery_box_registered": "Mailbox registered at %s, %s, %s.",
@@ -515,6 +517,7 @@
"item.sanctuary.mojang_classic_cape": "Mojang Classic Cape",
"item.sanctuary.blue_scroll_cape": "Blue Scroll Cape",
"item.sanctuary.white_cape": "White Cape",
"tooltip.sanctuary.chicken_armor": "Can also equip and protect an adult chicken.",
"dimension.sanctuary.alpha": "Alpha / Indev Island",
"dimension.sanctuary.backroom": "Backroom",
"message.sanctuary.backroom.unavailable": "The Backroom is unavailable in this world.",
@@ -527,5 +530,14 @@
"message.sanctuary.backroom.exited": "The bed returns you to your spawn point with your findings.",
"message.sanctuary.backroom.exited_with_losses": "The bed returns you with your findings and settles the recovered lost items.",
"screen.sanctuary.atlas.landmark.boss": "Defeated boss",
"message.sanctuary.notch_defeated": "Notch Titan has been defeated. The victory is now recorded in this world."
"message.sanctuary.notch_defeated": "Notch Titan has been defeated. The victory is now recorded in this world.",
"metric.sanctuary.ranking.play_time": "Play time",
"metric.sanctuary.ranking.mined": "%s mined",
"metric.sanctuary.ranking.crafted": "%s crafted",
"metric.sanctuary.ranking.used": "%s used",
"metric.sanctuary.ranking.broken": "%s broken",
"metric.sanctuary.ranking.picked_up": "%s picked up",
"metric.sanctuary.ranking.dropped": "%s dropped",
"metric.sanctuary.ranking.custom": "Custom statistic",
"metric.sanctuary.ranking.unknown": "Unknown statistic"
}
@@ -96,6 +96,7 @@
"message.sanctuary.quest_request.details.reward_legacy": "Cette ancienne demande doit être modifiée par son auteur pour déposer sa récompense.",
"message.sanctuary.quest_request.details.progress": "Progression : %s/%s.",
"message.sanctuary.quest_request.details.completed": "Demande terminée · récompense expédiée.",
"message.sanctuary.quest_request.details.completed_by": "Demande complétée par %s · récompense expédiée.",
"message.sanctuary.quest_request.accept": "[ACCEPTER LA QUÊTE]",
"message.sanctuary.quest_request.accept_hover": "Clique pour confirmer après avoir lu tous les détails.",
"message.sanctuary.quest_request.accept_prompt": "Lis les détails puis confirme dans la fenêtre d'acceptation.",
@@ -462,6 +463,7 @@
"sign.sanctuary.quest_request.available": "DISPONIBLE",
"sign.sanctuary.quest_request.claimed": "PRISE",
"sign.sanctuary.quest_request.completed": "TERMINÉE",
"sign.sanctuary.quest_request.completed_by": "Complétée par : %s",
"sign.sanctuary.quest_request.details": "Clic : détails",
"message.sanctuary.companion.active_locked": "Débloquez le pouvoir actif du familier dans Progression pour %s niveaux d'XP.",
"message.sanctuary.shop.delivery_box_registered": "Boîte aux lettres enregistrée en %s, %s, %s.",
@@ -515,6 +517,7 @@
"item.sanctuary.mojang_classic_cape": "Cape Mojang classique",
"item.sanctuary.blue_scroll_cape": "Cape parchemin bleu",
"item.sanctuary.white_cape": "Cape blanche",
"tooltip.sanctuary.chicken_armor": "Peut aussi équiper et protéger une poule adulte.",
"dimension.sanctuary.alpha": "Île Alpha / Indev",
"dimension.sanctuary.backroom": "Backroom",
"message.sanctuary.backroom.unavailable": "La Backroom nest pas disponible dans ce monde.",
@@ -527,5 +530,14 @@
"message.sanctuary.backroom.exited": "Le lit te ramène à ton point dapparition avec tes trouvailles.",
"message.sanctuary.backroom.exited_with_losses": "Le lit te ramène avec tes trouvailles et valide les objets perdus récupérés.",
"screen.sanctuary.atlas.landmark.boss": "Boss vaincu",
"message.sanctuary.notch_defeated": "Notch Titan a été vaincu. La victoire est désormais inscrite dans ce monde."
"message.sanctuary.notch_defeated": "Notch Titan a été vaincu. La victoire est désormais inscrite dans ce monde.",
"metric.sanctuary.ranking.play_time": "Temps de jeu",
"metric.sanctuary.ranking.mined": "%s extrait",
"metric.sanctuary.ranking.crafted": "%s fabriqué",
"metric.sanctuary.ranking.used": "%s utilisé",
"metric.sanctuary.ranking.broken": "%s cassé",
"metric.sanctuary.ranking.picked_up": "%s ramassé",
"metric.sanctuary.ranking.dropped": "%s jeté",
"metric.sanctuary.ranking.custom": "Statistique personnalisée",
"metric.sanctuary.ranking.unknown": "Statistique inconnue"
}
@@ -374,6 +374,7 @@
"sign.sanctuary.quest_request.available": "СВОБОДНО",
"sign.sanctuary.quest_request.claimed": "ЗАНЯТО",
"sign.sanctuary.quest_request.completed": "ВЫПОЛНЕНО",
"sign.sanctuary.quest_request.completed_by": "Выполнил: %s",
"sign.sanctuary.quest_request.details": "ПКМ: детали",
"message.sanctuary.quest_request.occupied": "На этой табличке уже есть запрос.",
"message.sanctuary.quest_request.protected": "Только заказчик или оператор может забрать эту табличку.",
@@ -390,6 +391,7 @@
"message.sanctuary.quest_request.details.reward_legacy": "Автор должен изменить этот старый запрос и внести награду.",
"message.sanctuary.quest_request.details.progress": "Прогресс: %s/%s.",
"message.sanctuary.quest_request.details.completed": "Запрос выполнен · награда отправлена.",
"message.sanctuary.quest_request.details.completed_by": "Запрос выполнил %s · награда отправлена.",
"message.sanctuary.quest_request.accept": "[ПРИНЯТЬ ЗАДАНИЕ]",
"message.sanctuary.quest_request.accept_hover": "Нажмите, чтобы подтвердить после чтения всех подробностей.",
"message.sanctuary.quest_request.accept_prompt": "Прочитайте подробности и подтвердите принятие в открывшемся окне.",
@@ -471,6 +473,7 @@
"item.sanctuary.mojang_classic_cape": "Классический плащ Mojang",
"item.sanctuary.blue_scroll_cape": "Плащ синего свитка",
"item.sanctuary.white_cape": "Белый плащ",
"tooltip.sanctuary.chicken_armor": "Также надевается на взрослую курицу и защищает её.",
"dimension.sanctuary.alpha": "Остров Альфа / Indev",
"dimension.sanctuary.backroom": "Бэкрум",
"message.sanctuary.backroom.unavailable": "Бэкрум недоступен в этом мире.",
@@ -483,5 +486,14 @@
"message.sanctuary.backroom.exited": "Кровать возвращает вас к точке возрождения со всеми находками.",
"message.sanctuary.backroom.exited_with_losses": "Кровать возвращает вас с находками и закрепляет найденные потерянные предметы.",
"screen.sanctuary.atlas.landmark.boss": "Побеждённый босс",
"message.sanctuary.notch_defeated": "Нотч-Титан побеждён. Победа навсегда записана в этом мире."
"message.sanctuary.notch_defeated": "Нотч-Титан побеждён. Победа навсегда записана в этом мире.",
"metric.sanctuary.ranking.play_time": "Время в игре",
"metric.sanctuary.ranking.mined": "Добыто: %s",
"metric.sanctuary.ranking.crafted": "Создано: %s",
"metric.sanctuary.ranking.used": "Использовано: %s",
"metric.sanctuary.ranking.broken": "Сломано: %s",
"metric.sanctuary.ranking.picked_up": "Подобрано: %s",
"metric.sanctuary.ranking.dropped": "Выброшено: %s",
"metric.sanctuary.ranking.custom": "Пользовательская статистика",
"metric.sanctuary.ranking.unknown": "Неизвестная статистика"
}
+1 -1
View File
@@ -32,7 +32,7 @@
"sanctuary26": {
"system_type": "gameplay-system",
"namespace": "sanctuary",
"data_version": 14,
"data_version": 15,
"network_protocol": 24,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
@@ -4,6 +4,7 @@
"compatibilityLevel": "JAVA_25",
"mixins": [
"AbstractContainerMenuMixin",
"AnimalChickenArmorMixin",
"BedBlockMixin",
"ChestBlockEntityMixin",
"ConsumableMixin",
@@ -15,6 +16,7 @@
"ItemEntityMixin",
"ItemTooltipMixin",
"LivingEntityCompanionMixin",
"LivingEntityChickenArmorMixin",
"PlayerOverflowMixin",
"SlotMixin",
"ServerGamePacketListenerMixin",
@@ -28,6 +30,8 @@
"client.AbstractContainerScreenMixin",
"client.AbstractRecipeBookScreenMixin",
"client.CameraMixin",
"client.ChickenRendererMixin",
"client.ChickenRenderStateMixin",
"client.CreativeModeInventoryScreenMixin",
"client.CreativeSlotWrapperAccessor",
"client.CreateWorldScreenWorldTabMixin",
@@ -0,0 +1,83 @@
package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import net.minecraft.SharedConstants;
import net.minecraft.resources.Identifier;
import net.minecraft.server.Bootstrap;
import net.minecraft.stats.Stats;
/** Pure ranking smoke for ordering, tied ranks, saturation and newer-save protection. */
public final class SanctuaryRankingModelSmoke {
private SanctuaryRankingModelSmoke() {}
public static void main(String[] args) throws Exception {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
Identifier metricId = Identifier.fromNamespaceAndPath("onlyfun", "opened_lootboxes");
RankingMetric metric = new RankingMetric(metricId,
"metric.onlyfun.ranking.opened_lootboxes", RankingUnit.COUNT);
UUID alex = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID bob = UUID.fromString("00000000-0000-0000-0000-000000000002");
UUID chris = UUID.fromString("00000000-0000-0000-0000-000000000003");
SanctuaryRankingData data = new SanctuaryRankingData();
data.recordValue(alex, "Alex", metricId, 10L);
data.recordValue(bob, "Bob", metricId, 10L);
data.recordValue(chris, "Chris", metricId, 5L);
RankingSnapshot tied = data.snapshot(metric, bob);
require(tied.podium().size() == 3, "Podium must remain bounded to available top three");
require(tied.podium().get(0).playerId().equals(alex),
"Earlier reached order must deterministically break a visual tie");
require(tied.podium().get(0).rank() == 1 && tied.podium().get(1).rank() == 1
&& tied.podium().get(2).rank() == 3, "Equal values must retain the same competition rank");
require(tied.owner().rank() == 1 && tied.owner().value() == 10L,
"Panel owner placement must reflect the real tied rank");
data.increment(chris, "Chris", metricId, Long.MAX_VALUE);
require(data.score(chris, metricId) == SanctuaryRankingData.MAX_VALUE,
"Public counters must saturate at the bounded long maximum");
Identifier usedStick = VanillaRankingMetric.id(Identifier.withDefaultNamespace("used"),
Identifier.withDefaultNamespace("stick"));
data.observeVanilla(alex, "Alex", usedStick, 100L, false);
data.observeVanilla(alex, "Alex", usedStick, 110L, false);
data.observeVanilla(alex, "Alex", usedStick, 115L, true);
require(data.score(alex, usedStick) == 5L,
"Creative or simulated vanilla deltas leaked into the public score");
data.observeVanilla(bob, "Bob", usedStick, 7L, true);
require(data.score(bob, usedStick) == 7L,
"A survival player's first observed vanilla statistics were discarded");
Identifier vanillaId = VanillaRankingMetric.id(
Identifier.withDefaultNamespace("custom"), Stats.PLAY_TIME);
require(VanillaRankingMetric.parse(vanillaId).orElseThrow().target().equals(Stats.PLAY_TIME)
&& VanillaRankingMetric.descriptor(vanillaId).unit() == RankingUnit.TICKS,
"Vanilla metric IDs and units must round-trip stably");
Path temporary = Files.createTempDirectory("sanctuary-ranking-migration-");
Path oldStats = temporary.resolve(alex + ".json");
Files.writeString(oldStats,
"{\"stats\":{\"minecraft:used\":{\"minecraft:stick\":12}}}", StandardCharsets.UTF_8);
SanctuaryRankingData imported = new SanctuaryRankingData();
imported.importVanillaFile(oldStats);
imported.importVanillaFile(oldStats);
require(imported.score(alex, usedStick) == 12L,
"Existing vanilla statistics were not imported monotonically exactly once");
Files.deleteIfExists(oldStats);
Files.deleteIfExists(temporary);
SanctuaryRankingData newer = new SanctuaryRankingData(
SanctuaryRankingData.DATA_VERSION + 1, true, 1L, Map.of());
newer.recordValue(alex, "Alex", metricId, 42L);
require(!newer.writable() && newer.score(alex, metricId) == 0L,
"A newer save must never be rewritten blindly");
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -25,6 +25,8 @@ public final class BackroomModelSmoke {
personalRecoveryIsPrioritized();
recentLossesRemainPersonal();
architectureIsDeterministicAndStacked();
lootLandmarksCoverEveryLevel();
exitBedsCoverEveryLevel();
roomCornersReachEveryVariant();
mazeDoorsStayConnected();
}
@@ -126,6 +128,45 @@ public final class BackroomModelSmoke {
}
}
private static void exitBedsCoverEveryLevel() {
long seed = 26_200_133L;
int cellSize = BackroomLayout.EXIT_BED_CELL_SIZE_CHUNKS;
require(cellSize == 3, "Backroom exit beds are no longer spaced three chunks apart");
for (BackroomLevel level : BackroomLevel.values()) {
int floorY = BackroomLayout.exitBedFloor(level);
require(BackroomLayout.levelAt(floorY) == level,
"An exit-bed floor escaped its Backroom level: " + level);
for (int cellX = -2; cellX <= 2; cellX++) for (int cellZ = -2; cellZ <= 2; cellZ++) {
int candidates = 0;
for (int chunkX = cellX * cellSize; chunkX < (cellX + 1) * cellSize; chunkX++) {
for (int chunkZ = cellZ * cellSize; chunkZ < (cellZ + 1) * cellSize; chunkZ++) {
if (BackroomLayout.exitBedChunk(seed, level, chunkX, chunkZ)) candidates++;
}
}
require(candidates == 1,
"A Backroom level lost its exit bed in a three-by-three chunk cell: " + level);
}
}
}
private static void lootLandmarksCoverEveryLevel() {
long seed = 26_200_137L;
int cellSize = BackroomLayout.LOOT_CHEST_CELL_SIZE_CHUNKS;
require(cellSize == 3, "Backroom loot landmarks are no longer spaced three chunks apart");
for (BackroomLevel level : BackroomLevel.values()) {
for (int cellX = -4; cellX <= 4; cellX++) for (int cellZ = -4; cellZ <= 4; cellZ++) {
int candidates = 0;
for (int chunkX = cellX * cellSize; chunkX < (cellX + 1) * cellSize; chunkX++) {
for (int chunkZ = cellZ * cellSize; chunkZ < (cellZ + 1) * cellSize; chunkZ++) {
if (BackroomLayout.lootChestChunk(seed, level, chunkX, chunkZ)) candidates++;
}
}
require(candidates == 1,
"A Backroom level lost its loot landmark in a three-by-three chunk cell: " + level);
}
}
}
private static void mazeDoorsStayConnected() {
long seed = 26_200_132L;
for (int x = -16; x <= 16; x++) for (int z = -16; z <= 16; z++) {
@@ -0,0 +1,37 @@
package fr.koka99cab.sanctuary26.sanctuary.gameplay;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.server.Bootstrap;
import net.minecraft.SharedConstants;
import net.minecraft.core.Holder;
import net.minecraft.core.component.DataComponentMap;
/** Executable coverage for the bounded adult-chicken armor equip policy. */
public final class SanctuaryChickenArmorSmoke {
private SanctuaryChickenArmorSmoke() {
}
public static void main(String[] args) {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
ItemStack armor = stack(Items.WOLF_ARMOR);
require(SanctuaryChickenArmor.canEquip(ItemStack.EMPTY, armor, false),
"Adult chickens must accept unused armadillo-scute armor");
require(!SanctuaryChickenArmor.canEquip(ItemStack.EMPTY, armor, true),
"Baby chickens must not accept body armor");
require(!SanctuaryChickenArmor.canEquip(armor.copy(), armor, false),
"Equipping must not replace an existing chicken armor stack");
require(!SanctuaryChickenArmor.canEquip(ItemStack.EMPTY,
stack(Items.IRON_CHESTPLATE), false),
"Chicken armor must stay limited to the armadillo-scute item");
}
private static ItemStack stack(net.minecraft.world.item.Item item) {
return new ItemStack(new Holder.Direct<>(item, DataComponentMap.EMPTY), 1);
}
private static void require(boolean condition, String message) {
if (!condition) throw new IllegalStateException(message);
}
}
@@ -38,6 +38,9 @@ public final class QuestRequestModelSmoke {
QuestRequestData.Request completed = claimed.withProgress(1).withCompletion();
require(completed.completed() && completed.progress() == completed.quantity()
&& completed.rewardEscrow().isEmpty(), "Completion must consume escrow exactly once");
require(completed.claim().orElseThrow().playerName().equals("Hunter")
&& completed.claim().orElseThrow().playerColor() == 0x44AAFF,
"Completion must preserve the player name and color displayed on the sign");
require(data.replace(claimed.withClaim(Optional.empty())), "Request release failed");
require(data.remove(data.request("request").orElseThrow()), "Recovering the request sign failed");
}