Compare commits

...
Author SHA1 Message Date
koka 26e305d15f Record beta.129 publication and verified Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 19:13:09 +02:00
koka 35103a7ea8 Add connected edge highlights and stable shadow projection for beta.129
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 19:11:11 +02:00
koka 6502883cc4 Document verified beta.128 publication and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 18:34:55 +02:00
koka a4e96c0d99 Stabilize pixel shadows and add moonlight and foliage for beta.128
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 18:33:07 +02:00
koka 7c63865157 Document verified beta.127 shader release and instance synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 17:53:16 +02:00
koka 00be1eb0d0 Integrate native pixel shadows over beta.126 for beta.127
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 17:51:19 +02:00
koka adde24aa7e Document verified beta.126 release and instance synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 17:11:59 +02:00
koka 99860af312 Add Sanctuary gems, original clay colors and shared discovery commands for beta.126
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 17:10:15 +02:00
koka 1547396c06 Document verified beta.125 release and instance synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 16:37:18 +02:00
koka 4ec5b0d028 Add sculpture corner and height placement, voxel chips and discovered workshop models
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 16:35:46 +02:00
koka 67e6e7c4f2 Record beta.124 publication and preserved Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 16:02:47 +02:00
koka 00179e30ef Use one cached bounding box per sculpture (beta.124)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 16:00:52 +02:00
koka 87f823af67 Record beta.123 publication and preserved Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 15:30:04 +02:00
koka 4552cc347b Separate safe golden rotations from Steve wrench powers (beta.123)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 15:28:06 +02:00
koka 49131b4c53 Record beta.122 publication and preserved Prism sync
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 15:04:59 +02:00
koka edc7fc3dfb Match sculpture collisions to voxels and fix light occlusion (beta.122)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 15:02:05 +02:00
koka 9b0104d27a Record beta.121 publication and preserved Prism sync
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 14:11:39 +02:00
koka d9b3dd6bed Add editable Sanctuary resource template and new wrench texture (beta.121)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 14:09:11 +02:00
koka e732da8800 Record beta.120 publication and verified Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 13:44:30 +02:00
koka 248dff47b7 Extend golden wrench to native block states and persistent texture rotation (beta.120)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 13:43:02 +02:00
koka 6aaabf1cb9 Normalize sculpture delivery note formatting
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 13:16:24 +02:00
koka 72e9263339 Record beta.119 release and verified Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 13:16:12 +02:00
koka 3fd64e1f2a Center sculptures on aimed block middle without half-voxel offsets (beta.119)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 13:14:54 +02:00
koka 8af406a028 Record verified beta.118 publication and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 13:05:40 +02:00
koka 0a81b3c147 Add sixteen native colored brick walls (beta.118)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 13:03:18 +02:00
koka 68fd97d287 Record verified beta.117 publication and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 12:19:54 +02:00
koka 0f1efdec83 Sample texture grain for sculptures and preserve worn models (beta.117)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 12:18:10 +02:00
koka 5c666a4ebc Record verified beta.116 publication and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 07:50:34 +02:00
koka e3ca9fcd63 Apply block texture palettes to workshop sculptures (beta.116)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 07:49:00 +02:00
koka 22e58b4b91 Fix multiblock furnace slot mapping and explain shared cooking (beta.115)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 07:28:40 +02:00
koka ed0e0b26aa Document beta.114 release and verified instance update
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 06:59:17 +02:00
koka 21845bd736 Add chaotic flight when carried flying animals are struck (beta.114)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 06:57:34 +02:00
koka 4a6f63c52d Document beta.113 publication and verified Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 06:39:24 +02:00
koka 6166d0aa9e Snap clay sculptures to aimed block edges on the voxel grid (beta.113)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 06:38:04 +02:00
koka 064701f210 Document beta.112 publication and verified Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 04:01:02 +02:00
koka 1eaa976ecf Use only the active hotbar in the clay workshop for beta.112
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 03:59:13 +02:00
koka 8c0020c774 Document verified beta.111 publication and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 03:45:23 +02:00
koka 289697becf Refine clay workshop and orient statues on placement for beta.111
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 03:43:36 +02:00
koka e38f6cbdbe Record beta.110 publication and verified Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 03:15:20 +02:00
koka 2090380712 Allow the indexed pack icon in packwiz channel publication
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 03:12:24 +02:00
koka 468743bfe7 Integrate verified Sanctuary beta.110 with clay workshop, eggs and seasonal snow
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 03:10:39 +02:00
1485 changed files with 54988 additions and 617 deletions
+130
View File
@@ -1,5 +1,135 @@
# Changelog
## beta.110 — Atelier dargile inclus
- Une seule livraison réunit latelier dargile, les statuaires et limport GLB de beta.106 avec les œufs et la neige saisonnière de beta.107 à beta.109.
- Fonctionnement, identifiants et formats conservés ; accumulation toujours plafonnée à deux blocs par défaut.
- [Contrat et vérifications](docs/clay-workshop-integration-beta110.md).
## beta.109 — Fonte saisonnière
- Fonte par couches des dépôts météo suivis, de haut en bas, plus rapide en été.
- Arrêt en hiver, dans les biomes froids, sous un profil neigeux ou en mode météo Vanilla.
- Constructions et neige antérieure préservées ; suivi sauvegardé par chunk, retiré lors des modifications manuelles.
- Gamerule `sanctuary:seasonal_snow_melt` active par défaut ; accumulation toujours limitée à deux blocs par défaut.
- [Contrat et essais](docs/seasonal-snow-melt-beta109.md), sur la base beta.108, Statuaire beta.106 livré séparément.
## beta.108 — Accumulation de neige
- Chaque précipitation ajoute une couche ; la huitième forme un bloc de neige natif solide, puis le dépôt continue au-dessus.
- Plafond de deux blocs par défaut, gamerule réglable (0 = arrêt, -1 = sans plafond particulier).
- Collisions, supports, lumière et chaudrons natifs conservés ; accumulation seulement pendant la chute de neige.
- [Contrat et essais](docs/snow-accumulation-beta108.md). Assemblage isolé sur beta.107, avec les œufs, sans le Statuaire beta.106 livré séparément.
## beta.107 — Naissances en œufs
- Reproduction : œuf natif au lieu dun bébé vivant, variantes et caractéristiques héritées ; bébé garanti à l’éclosion.
- Grenouille/têtard, tortue, sniffer, allay et villageois : chemins natifs particuliers pris en charge.
- Générateur classique cassé en survie sans Toucher de soie : un œuf de son type.
- Catalogue des 88 espèces : 35 voies couvertes et 53 propositions à valider, sans ajouter ces propositions au jeu.
- [Contrat et contrôles](docs/spawn-eggs-acquisition-beta107.md), [catalogue](docs/spawn-eggs-catalogue-beta107.md).
- Livraison isolée sur la base beta.105 ; le chantier Statuaire beta.106 est conservé séparément.
## beta.106 — Atelier dargile et modèles 3D
- Dossier commun pour schémas et GLB ; sélection des modèles 3D dans Statues au Métabli.
- Atelier dargile : un bloc dargile pour un statuaire 16³ à poser. Couleurs du modèle avec largile normale, couleur unie avec les seize argiles Sanctuary.
- Modèle identifié et embarqué dans lobjet, sauvegarde serveur et rendu natif de la miniature dans le monde et linventaire.
- Contrat dimport, de sauvegarde et limites : `docs/statuary-beta106.md`.
## beta.105 — Saisons et chapeaux vivants
- Molette pour les pages du fût ; panoramas avec les effets actifs du shader Sanctuary.
- Prévisions saisonnières, neige continue ou averses de neige avec accumulation native.
- Chapeaux réactifs : paratonnerre, briquet, spawner, fusée, cultures et aliments attirants.
- Livre et plume : journal personnel selon le caractère et les caresses/blessures.
- Les boules de neige activent les objets portés. Aucune Weather TNT.
- [Contrat et vérifications](docs/living-hats-seasons-beta105.md).
## beta.104 — Contraste des argiles
- Gris soutenu et noir plus sombre, dessin vanilla plus contrasté.
- Boules dargile accordées aux blocs, autres textures intactes.
- Rangement beta.103 conservé ; [détails](docs/clay-contrast-beta104.md).
## beta.103 — Rangement créatif
- Séries de 16 par type : briques, escaliers, dalles, argiles et leurs items.
- Ordre des couleurs identique à longlet natif Minecraft 26.3.
- [Détails](docs/colored-bricks-order-beta103.md).
## beta.102 — Briques et argile de couleur
- 16 familles : briques, dalles, escaliers, argile pastel, boules dargile et briques en items.
- Textures fournies intactes, recoloration pixel par pixel des trois textures vanilla.
- Onglets natifs Couleurs/Ingrédients, recettes, cuisson, tailleur, butin et découvertes.
- [Contrat et vérifications](docs/colored-bricks-beta102.md).
## beta.101 — Atterrissage des familiers volants
- Les profils volants ne subissent plus les dégâts et effets de chute de leur entité commune.
- Les vrais coups et les chutes des familiers terrestres sont conservés.
- [Reproduction et vérifications](docs/flying-landing-beta101.md).
## beta.100 — Exploration et promenade des familiers
- Statues limitées aux espèces rencontrées, vaincues ou ayant tué le joueur.
- Catalogue et génération filtrés par une liste de découvertes fournie par le serveur.
- Promenade avec destinations stables et pauses selon le tempérament, y compris en vol et en garde.
- Import `.schem` : conversion native des anciennes palettes dont la version est connue, diagnostic du bloc incompatible.
- [Contrat et vérifications](docs/exploration-familiers-beta100.md).
## beta.099 — Catalogue, compétences et gestes K
- Compétences avant les progrès ; arbre natif intégré et suivi par clic droit.
- Fiches illustrées communes, trois plans récents terminés, infrastructures et décoration.
- Palette de statue étendue aux blocs du registre, avec comparaison couleur et forme.
- K bref : ancrer au point visé ou tourner au même point ; K maintenu : commandes.
- Métabli préassemblé sur le piédestal des nouvelles salles souterraines.
- Clé dorée en outil plat, texture vanilla provisoire en attente de celle du créateur.
- [Contrat et vérifications](docs/catalogue-progression-beta099.md).
## beta.098 — Construction créative
- Bouton « Construire le plan… » dans K, visible uniquement en créatif.
- Confirmation du plan complet à lorigine et dans lorientation sélectionnées.
- Statues, bâtiments et composants de machines ; assemblage multibloc à la clé.
- [Détails et vérifications](docs/creative-construction-beta098.md).
## beta.097 — Temples et aperçu texturé
- Recherche de secours pour les temples des expéditions, avec pièces vanilla et fondation locale si nécessaire.
- Conserve la première recherche et les emplacements déjà admissibles.
- Aperçu des plans avec les modèles et textures des blocs, formes natives et nom du matériau visé.
- [Contrat et vérifications](docs/temple-crash-beta097.md).
## beta.096 — Métabli
- Atelier de neuf établis avec les textures fournies, assemblage et dissociation à la clé.
- Catalogue Statues, Machines, Bâtiments et Mes plans ; sélection unique à latelier.
- K garde les commandes de placement, rotation, couches et matériaux.
- Construction manuelle ; changer de projet ou labandonner exige de revenir à latelier.
- [Détails et vérifications](docs/metabli-beta096.md).
## beta.095 — Pilier des super pistons
- Bois du centre de la grande plaque sur la tige, sans bordure ni étirement.
- Découpage adapté aux segments courts du socle et de la tête.
- [Détails et vérifications](docs/piston-shaft-beta095.md).
## beta.094 — Feu du Fourneau
- Braises dans les deux ouvertures, allumage commun lié à la chaleur.
- Flammes et fumée alignées sur la façade dans les quatre orientations.
- Pierre originale et fours isolés conservés.
- [Détails et vérifications](docs/fourneau-allume-beta094.md).
## beta.093 — Crash des infobulles pendant la recherche
- Retire les accès à la police graphique lors de lindexation asynchrone des
descriptions de familiers ; texte complet et mise en page visible conservés.
- [Diagnostic et vérifications](docs/tooltip-crash-beta093.md).
## beta.092 — Clic molette des super pistons
- Socles, têtes et tiges renvoient le piston normal ou collant dorigine.
+316 -6
View File
@@ -20,10 +20,18 @@ La [vision complète](docs/vision.md) conserve les intentions ; le
L’économie, les claims et les dimensions décrits dans la vision restent
à implémenter.
Les sources Git sont synchronisées jusqu’à **beta.092**. Le récapitulatif de
[synchronisation](docs/git-sync-beta092.md) précise les vérifications et les
limites. Les archives bêta mentionnées dans ce document sont des fichiers
locaux ; la dernière release du canal packwiz reste **alpha.30.7**.
La version **beta.129** ajoute les reflets CTM optionnels sur les contours du
relief, avec une portée indépendante de 16 à 256 blocs. Les surfaces plates
se raccordent entre tous les matériaux. Les ombres gagnent les portées
128/256, gardent leur intensité à midi et ne réalignent plus leur grille à
chaque mouvement du soleil. **Options → Shaders → Sanctuary · Vanilla Light**.
Le [ticket de livraison](docs/shader-beta129.md) précise le résultat et ses
vérifications. La release est publiée et linstance **Sanctuary Beta** est
synchronisée, sauvegardes et réglages conservés. Packs normal/Test et template
sont disponibles dans `build/`.
La [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà
latelier, les œufs et la neige saisonnière dans linstance **Sanctuary Beta**.
Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées.
Les [archives pour le site](archives/web/README.md) conservent la maquette
Blocodex autonome, les documents et les images retrouvées jusqu'à alpha.30.7.
@@ -31,6 +39,308 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
l’étude de Xaero et des alternatives libres.
## beta.129 — Reflets CTM et ombres jusqu'à 256 blocs
Reflets connectés réglables, cinq portées indépendantes, soleil plein à midi
et correction du réalignement cyclique de la grille d'ombres.
[Contrat et vérifications](docs/shader-beta129.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.129/Sanctuary-beta.129.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.129/Sanctuary-Test-beta.129.mrpack).
## beta.128 — Ombres stables, lune et feuillage
Grille de réception stabilisée, contours nets des blocs, atténuation à midi,
ombre lunaire légère et feuillage nuancé selon son épaisseur.
[Contrat et vérifications](docs/shader-beta128.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.128/Sanctuary-beta.128.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.128/Sanctuary-Test-beta.128.mrpack).
## beta.127 — Shader : ombres portées pixélisées
Ombres solaires natives, finesse 8/16/32 pixels par bloc et portée 16/32/64 blocs.
Option désactivée par défaut, réglable sans rechargement des ressources.
[Contrat et vérifications](docs/shader-beta127.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.127/Sanctuary-beta.127.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.127/Sanctuary-Test-beta.127.mrpack).
## beta.126 — Gemmes, argile et découvertes
Rubis et saphir avec filons configurables, couleurs originales avec l'argile,
commandes de découverte globales ou ciblées.
[Contrat et vérifications](docs/gems-beta126.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.126/Sanctuary-beta.126.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.126/Sanctuary-Test-beta.126.mrpack).
## beta.125 — Sculptures : placement, éclats et animaux
Coins et trois hauteurs sur la grille 1/16 ; particules issues des voxels ;
modèles découverts disponibles dans le Clay Workshop.
[Contrat et vérifications](docs/sculpture-placement-beta125.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.125/Sanctuary-beta.125.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.125/Sanctuary-Test-beta.125.mrpack).
## beta.124 — Une boîte par sculpture
Sélection et collisions simplifiées, dimensions ajustées et lumière conservée.
[Contrat et vérifications](docs/sculpture-bounds-beta124.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.124/Sanctuary-beta.124.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.124/Sanctuary-Test-beta.124.mrpack).
## beta.123 — Clé dorée et clé de Steve
Orientations cohérentes, inventaires et eau préservés ; clé de Steve pour
l’édition libre, utilisable aussi en survie. [Contrat et vérifications](docs/wrench-safety-beta123.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.123/Sanctuary-beta.123.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.123/Sanctuary-Test-beta.123.mrpack).
## beta.122 — Sculptures : forme et lumière
Collisions et sélection sur les voxels réels, creux traversables, lumière sans
occlusion cubique. [Contrat et vérifications](docs/sculpture-shape-light-beta122.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.122/Sanctuary-beta.122.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.122/Sanctuary-Test-beta.122.mrpack).
## beta.121 — Texture de clé et pack personnel
Copie complète des ressources, création depuis le jeu et guide FR/EN inclus.
[Contrat et vérifications](docs/resourcepack-template-beta121.md) ·
[Template personnel](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-Template-beta.121.zip) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-beta.121.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-Test-beta.121.mrpack).
## beta.120 — Clé dorée et états de blocs
Gestes du debug stick, rotation de textures et assemblage des machines conservé.
[Contrat et vérifications](docs/golden-wrench-beta120.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.120/Sanctuary-beta.120.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.120/Sanctuary-Test-beta.120.mrpack).
## beta.119 — Sculptures au centre ou au bord
Pose selon le point visé, centrage sur la grille entière et ancrage conservé
en sauvegarde. [Contrat et vérifications](docs/sculpture-center-beta119.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.119/Sanctuary-beta.119.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.119/Sanctuary-Test-beta.119.mrpack).
## beta.118 — Murets en briques colorées
Seize couleurs, fabrication et tailleur de pierre, eau, raccords et collisions
natifs. [Contrat et vérifications](docs/colored-brick-walls-beta118.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.118/Sanctuary-beta.118.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.118/Sanctuary-Test-beta.118.mrpack).
## beta.117 — Grain des textures et sculptures en chapeau
Les petits cubes d'une sculpture reprennent plusieurs nuances des textures
de la matière. Le modèle reste intact dans le slot chapeau et au retrait.
[Contrat et vérifications](docs/sculpture-texture-grain-beta117.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.117/Sanctuary-beta.117.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.117/Sanctuary-Test-beta.117.mrpack).
## beta.116 — Matières de sculpture et Fourneau
Un bloc de matière devient une sculpture aux couleurs de sa texture. Hotbar,
orientation et ancrage conservés. Le Fourneau accepte les dépôts dans chacune
de ses 27 cases et explique sa chauffe commune.
[Contrat et vérifications](docs/sculpture-materials-beta116.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.116/Sanctuary-beta.116.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.116/Sanctuary-Test-beta.116.mrpack).
## beta.115 — Fourneau : dépôts et chauffe
Correctif du décalage par trois cases, combustible stable et progression
par cuisson. [Diagnostic et essais](docs/fourneau-transfers-beta115.md).
Étape locale intégrée à la livraison commune beta.116.
## beta.114 — Animaux portés et fuite en vol
Un animal volant porté entraîne son porteur après un coup. La trajectoire
aléatoire prend brièvement le dessus sur les commandes ; déposer lanimal
interrompt le vol. [Contrat et vérifications](docs/carried-flight-panic-beta114.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.114/Sanctuary-beta.114.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.114/Sanctuary-Test-beta.114.mrpack).
## beta.113 — Sculptures dargile et alignement
Pose contre le bord visé, orientation selon le regard et coordonnées entières
pour chaque voxel, y compris les modèles de largeur impaire. Laccroche est
conservée à la reconnexion. [Contrat et vérifications](docs/clay-sculpture-grid-beta113.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.113/Sanctuary-beta.113.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.113/Sanctuary-Test-beta.113.mrpack).
## beta.112 — Atelier dargile et hotbar
Neuf cases de hotbar et deux emplacements de fabrication, dans un cadre fixe.
Les transferts restent dans la hotbar ; une barre pleine ne consomme pas dargile.
[Contrat et vérifications](docs/clay-workshop-hotbar-beta112.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.112/Sanctuary-beta.112.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.112/Sanctuary-Test-beta.112.mrpack).
## beta.111 — Atelier dargile et pose orientée
Cadre nine-slice, aperçu, fabrication et inventaire dans un menu commun.
Les statuaires sorientent selon le regard horizontal à chaque pose ; les
anciens modèles gardent leur apparence. [Contrat et vérifications](docs/clay-workshop-ui-beta111.md).
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.111/Sanctuary-beta.111.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.111/Sanctuary-Test-beta.111.mrpack) ·
[Release](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.111).
## beta.110 — Atelier dargile et neige saisonnière réunis
Le Clay Workshop / atelier dargile, les statuaires et les modèles 3D rejoignent
les œufs, laccumulation et la fonte saisonnière dans un seul pack.
[Contrat et vérifications](docs/clay-workshop-integration-beta110.md).
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.110/Sanctuary-beta.110.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.110/Sanctuary-Test-beta.110.mrpack) ·
[Release et installation Prism](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.110).
## beta.109 — Fonte saisonnière
La neige météo fond progressivement hors hiver, avec un rythme adapté à la
saison. Les constructions en neige sont préservées ; accumulation plafonnée
à deux blocs par défaut. [Contrat et essais](docs/seasonal-snow-melt-beta109.md).
Base beta.108, Statuaire beta.106 livré séparément.
[Pack normal](build/Sanctuary-beta.109.mrpack) ·
[Pack de test](build/Sanctuary-Test-beta.109.mrpack).
## beta.108 — Neige en volume
Huit couches deviennent un bloc de neige solide ; les dépôts se poursuivent
au-dessus. Hauteur par défaut de deux blocs, réglable par gamerule.
[Contrat et contrôles](docs/snow-accumulation-beta108.md).
Base beta.107 conservée, avec ses œufs ; Statuaire beta.106 livré séparément.
[Pack normal](build/Sanctuary-beta.108.mrpack) ·
[Pack de test](build/Sanctuary-Test-beta.108.mrpack).
## beta.107 — Œufs de reproduction et de générateurs
Les naissances deviennent des œufs transportables, avec bébé et héritage natif.
Les générateurs classiques donnent leur œuf lorsquils sont cassés sans Toucher
de soie. [Détails](docs/spawn-eggs-acquisition-beta107.md) ·
[Catalogue des 88 espèces à valider](docs/spawn-eggs-catalogue-beta107.md).
Assemblage isolé sur la base beta.105, pendant le chantier Statuaire beta.106.
[Pack normal](build/Sanctuary-beta.107.mrpack) ·
[Pack de test](build/Sanctuary-Test-beta.107.mrpack).
## beta.106 — Atelier dargile et modèles 3D
Un même dossier pour schémas et fichiers GLB. Le Métabli les transforme en
grandes statues en blocs ; latelier dargile fabrique des statuaires 16³
transportables et posables. Argile normale : couleurs dorigine ; argile
colorée : sculpture unie. [Contrat et validation](docs/statuary-beta106.md).
[Pack normal](build/Sanctuary-beta.106.mrpack) ·
[Pack de test](build/Sanctuary-Test-beta.106.mrpack).
## beta.105 — Saisons et chapeaux vivants
Molette dans le fût, panoramas avec le shader Sanctuary, prévisions saisonnières
et neige au sol. Les mobs équipés utilisent leurs objets, attirent les animaux
et écrivent leur journal. [Détails](docs/living-hats-seasons-beta105.md).
[Pack normal](build/Sanctuary-beta.105.mrpack) ·
[Pack de test](build/Sanctuary-Test-beta.105.mrpack).
## beta.104 — Argiles grise et noire
Teintes plus distinctes et contraste renforcé, sur les blocs et leurs items.
Le rangement par séries de 16 est conservé. [Détails](docs/clay-contrast-beta104.md) ·
[Pack normal](build/Sanctuary-beta.104.mrpack) ·
[Pack de test](build/Sanctuary-Test-beta.104.mrpack).
## beta.103 — Rangement par séries de 16
Briques, escaliers, dalles et argiles regroupés par type, dans lordre des
couleurs natif. [Détails](docs/colored-bricks-order-beta103.md) ·
[Pack normal](build/Sanctuary-beta.103.mrpack) ·
[Pack de test](build/Sanctuary-Test-beta.103.mrpack).
## beta.102 — Briques et argile de couleur
Seize couleurs : briques, dalles, escaliers, argile pastel et leurs items.
Blocs dans longlet natif des couleurs ; recettes et textures 16 × 16.
[Détails](docs/colored-bricks-beta102.md) ·
[Pack normal](build/Sanctuary-beta.102.mrpack) ·
[Pack de test](build/Sanctuary-Test-beta.102.mrpack).
## beta.101 — Atterrissage des familiers volants
Les familiers volants ne prennent plus de coup de chute au contact du sol.
Les vrais dégâts de combat restent actifs.
[Validation](docs/flying-landing-beta101.md) ·
[Pack normal](build/Sanctuary-beta.101.mrpack) ·
[Pack de test](build/Sanctuary-Test-beta.101.mrpack).
## beta.100 — Exploration et familiers
Les statues se débloquent par rencontre ou combat. Les familiers se promènent
avec des pauses selon leur tempérament ; les volants ne tournent plus en orbite
permanente. [Détails et validation](docs/exploration-familiers-beta100.md).
[Pack normal](build/Sanctuary-beta.100.mrpack) ·
[Pack de test](build/Sanctuary-Test-beta.100.mrpack).
## beta.099 — Catalogue du Métabli et Progression
Compétences en haut, arbre des progrès directement dessous. Le Métabli propose
un catalogue à aperçus, les trois derniers plans terminés et une palette étendue
pour les statues. K bref place/tourne, K maintenu ouvre les commandes.
Les nouvelles grandes salles souterraines accueillent un Métabli sur leur piédestal.
[Détails et vérifications](docs/catalogue-progression-beta099.md).
[Pack normal](build/Sanctuary-beta.099.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.099.mrpack).
## beta.098 — Construire le plan en créatif
Après sélection au Métabli, K propose **Construire le plan…** uniquement en
créatif. Statues, bâtiments et composants de machines sont posés selon
lorigine et lorientation choisies, après confirmation.
[Détails et vérifications](docs/creative-construction-beta098.md).
[Pack normal](build/Sanctuary-beta.098.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.098.mrpack).
## beta.097 — Temples et aperçu texturé
Corrige le chargement de certaines graines sans emplacement naturel de temple :
recherche élargie, puis présence forcée des pièces vanilla avec fondation locale
si nécessaire. Les aperçus de construction utilisent les modèles et textures
des blocs du pack actif et indiquent le matériau visé.
[Détails et vérifications](docs/temple-crash-beta097.md).
[Pack normal](build/Sanctuary-beta.097.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.097.mrpack).
## beta.096 — Métabli et construction guidée
Neuf établis à plat, assemblés à la clé, ouvrent le catalogue de statues,
machines, bâtiments et plans personnels. Un choix ferme le catalogue ; K
permet ensuite de placer et dorienter laperçu. Changer ou abandonner le projet
se fait au Métabli. Construction manuelle avec ses matériaux.
[Détails et vérifications](docs/metabli-beta096.md).
[Pack normal](build/Sanctuary-beta.096.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.096.mrpack).
## beta.095 — Texture du pilier des super pistons
La tige centrale utilise uniquement le bois au centre de la grande plaque,
y compris ses segments courts dans le socle et la tête.
[Détails et vérifications](docs/piston-shaft-beta095.md).
[Pack normal](build/Sanctuary-beta.095.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.095.mrpack).
## beta.094 — Fourneau allumé
Les deux foyers brillent pendant la chauffe ; flammes et fumée sortent des
ouvertures de la façade. La pierre dorigine est conservée.
[Détails et vérifications](docs/fourneau-allume-beta094.md).
[Pack normal](build/Sanctuary-beta.094.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.094.mrpack).
## beta.093 — Crash de recherche créative
Les descriptions de familiers peuvent être indexées en arrière-plan sans accéder
à la police graphique. Leur mise en page reste conservée à laffichage.
[Diagnostic et vérifications](docs/tooltip-crash-beta093.md).
[Pack normal](build/Sanctuary-beta.093.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.093.mrpack).
## beta.092 — Clic molette des super pistons
Viser un socle, une tête ou une tige sélectionne le piston normal ou collant
@@ -1896,7 +2206,7 @@ Depuis beta.090, la cible est **Minecraft 26.3 finale**, publiée le 15 septembr
| Fabric API | 0.160.5+26.3 |
| Fabric Loom | 1.17.20 |
| Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 |
| Sanctuary / pack | beta.092 |
| Sanctuary / pack | beta.122 |
Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise
uniquement la bibliothèque standard et repère aussi une installation Python
@@ -1925,7 +2235,7 @@ décrits dans [Validation](docs/testing.md).
Résultats :
- `mods/sanctuary/build/libs/sanctuary-beta.092.jar` : mod à installer avec
- `mods/sanctuary/build/libs/sanctuary-beta.122.jar` : mod à installer avec
Fabric API sur la version Minecraft indiquée.
- `build/packwiz/` : pack de développement complet, avec le mod construit et
l'index vérifié. Voir [Installation packwiz](packwiz/README.md).
+19
View File
@@ -117,3 +117,22 @@ commit `62513191f6a3497447595c2215d466ad1d2bdb92`, sous Unlicense.
Le comportement des contours et leurs coordonnées datlas suivent cette source ;
lintégration, la synchronisation et le calcul de capacité sont propres à Sanctuary.
Texte de licence : [Unlicense amont](https://github.com/squeek502/AppleSkin/blob/62513191f6a3497447595c2215d466ad1d2bdb92/LICENSE).
## Briques et argile beta.102
Les seize textures de blocs de briques ont été fournies par le créateur de
Sanctuary. Les variantes dargile, de boule dargile et de brique sont des
recolorations des textures Minecraft 26.3 de Mojang/Microsoft. Les ressources
de modèles, butin et états reprennent leurs équivalents natifs 26.3.
`tools/generate-colored-bricks.py` documente cette transformation ;
`tools/colored-bricks-palette.json` conserve couleurs et empreintes des PNG fournis.
Ces ressources dérivées ne sont pas présentées comme des créations originales.
## Clé dorée beta.121
Le PNG 16 × 16 `golden_wrench.png` a été fourni par le créateur de Sanctuary
le 17 septembre 2026. Il est conservé octet pour octet dans le mod, le pack
intégré et le template personnel (SHA-256
`a97570908db75caf8e6fc2f5ecabf12c54fd99bfea7be634df66c4310dca22af`).
Le template regroupe les ressources client déjà distribuées par Sanctuary ;
leurs crédits et conditions respectives restent applicables.
+19 -1
View File
@@ -15,7 +15,7 @@ tasks.register('verifyPack', Exec) {
tasks.register('assemblePack', Exec) {
group = 'distribution'
description = 'Assemble a local packwiz pack including the built Sanctuary mod.'
dependsOn(':sanctuary:build', 'verifyPack', 'assembleResourcePack')
dependsOn(':sanctuary:build', 'verifyPack', 'assembleResourcePack', 'assembleResourceTemplate')
commandLine('python3', 'scripts/pack.py', 'assemble')
}
@@ -46,3 +46,21 @@ tasks.register('assembleTestPack', Exec) {
dependsOn('assemblePack', ':sanctuary-test:build')
commandLine('python3', 'scripts/test_pack.py')
}
tasks.register('assembleResourceTemplate', Zip) {
group = 'distribution'
description = 'Export all Sanctuary client assets as an editable personal resource pack.'
dependsOn(':sanctuary:processResources', 'verifyResourcePack')
def resources = project(':sanctuary').layout.buildDirectory.dir('resources/main')
// Same precedence as the native exporter: built-in textures override mod defaults.
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from(resources.map { it.dir('resourcepacks/textures/assets') }) { into 'assets' }
from(resources.map { it.dir('assets') }) { into 'assets' }
from(resources.map { it.dir('resourcepacks/template') })
from(resources.map { it.file('resourcepacks/textures/pack.png') })
exclude '**/.DS_Store'
destinationDirectory = layout.buildDirectory
archiveFileName = "Sanctuary-Template-${mod_version}.zip"
preserveFileTimestamps = false
reproducibleFileOrder = true
}
+135
View File
@@ -1,5 +1,44 @@
# Backlog Sanctuary
## STAT-03 — Atelier dargile et hotbar — beta.112
Branche `codex/clay-workshop-hotbar-beta112`. [Contrat et vérifications](clay-workshop-hotbar-beta112.md).
Onze cases : argile, résultat et hotbar active ; hauteur fixe et transferts
limités aux neuf cases, même avec un inventaire agrandi.
## STAT-02 — Atelier dargile et orientation — beta.111
Branche `codex/clay-workshop-ui-beta111`. [Contrat et vérifications](clay-workshop-ui-beta111.md).
Cadre nine-slice fourni, inventaire intégré et pose selon le regard horizontal.
Propriété `facing` additive ; les anciens statuaires gardent leur apparence.
## STAT-01 — Atelier dargile et modèles 3D — beta.106
Branche `codex/statuary-import-beta106`. [Contrat et validation](statuary-beta106.md).
Dossier commun, GLB au Métabli pour les statues en blocs, atelier dargile
pour les miniatures 16³ avec consommation native, teinte et identité du modèle.
Cycle natif de fabrication/pose/récupération/rechargement vérifié, y compris
sans fichier source. Build complet et archives normale/Test vérifiés localement.
## BUILD-104 — Contraste des argiles
Branche `codex/clay-contrast-beta104`. [Ticket](clay-contrast-beta104.md).
Gris et noir plus distincts ; blocs et items accordés. Aperçu, contraste,
transparence, build complet et archives normal/Test vérifiés.
## BUILD-103 — Rangement créatif par séries
Branche `codex/colored-bricks-order-beta103`. [Ticket](colored-bricks-order-beta103.md).
Groupes de seize par type et ordre de couleurs natif. Build complet et
archives normal/Test vérifiés ; ressources beta.102 inchangées.
## BUILD-102 — Briques et argile de couleur
Branche `codex/colored-bricks-beta102`. [Ticket](colored-bricks-beta102.md).
Seize familles natives et textures recolorées à partir des originaux vanilla.
Parcours natif, recettes, butin, modèles, sauvegarde/réouverture, build complet
et archives normal/Test vérifiés.
## SKY-077 — tourbillon au-dessus du vide
Branche `codex/cloud-vortex-beta077`. [Ticket](cloud-vortex-beta077.md).
@@ -40,6 +79,18 @@ Capture native des 88 espèces, parcours creeper créatif/survie FR/EN,
interopérabilité Litemapy, sept tests serveur et 130 tâches Gradle réussis.
Archives normal/Test vérifiées ; anciennes `.schematic` à convertir en externe.
## BUILD-02 — Métabli et menu de construction
**Premier lot implémenté en beta.096**, branche `codex/metabli-beta096`.
[Contrat et vérifications](metabli-beta096.md) ·
[Conception et extension future du catalogue](metabli-construction.md).
Neuf établis à plat ouvrent le catalogue ; choix dun projet à latelier,
placement et orientation avec K. Statues, quatre machines existantes, abri,
passerelle et imports ; construction manuelle guidée, sans bouton pause ajouté.
Changer/importer/abandonner exige le Métabli. Projet local à la session actuelle,
exportable ; chantiers partagés persistants, catalogue serveur en datapack et
fonctions commerciales restent à réaliser.
## MB-01 — Reprendre le catalogue des multiblocs
**Ticket local ouvert le 15 septembre 2026 ; premier lot implémenté en beta.084.**
@@ -1726,3 +1777,87 @@ Voir [la génération](generation-alpha30.5.md) et [latlas](terrain-atlas.md)
Livré et vérifié : trois modes de combat, inscriptions sans plafond pour les arènes,
paris en objets, annonces et liste publique. Joueurs avec morts réelles.
[Contrat et essais](arenas-beta073.md).
## EXP-03 — Crash de recherche de temple
Correctif local beta.097 sur `codex/expedition-temple-crash-beta097`.
[Contrat et vérifications](temple-crash-beta097.md). Graine du rapport
`-4700804240597771092` ; le créateur demande de garantir le temple plutôt que
domettre le bâtiment ou de refuser la graine. Les plans de construction gagnent
aussi leur aperçu texturé à sa demande pendant ce correctif.
## CONSTRUCTION-098 — construction directe en créatif
Branche `codex/creative-construction-beta098`.
[Ticket](creative-construction-beta098.md) : bouton K créatif, confirmation et
placement serveur du projet choisi au Métabli ; parcours manuel en survie.
Essai client natif (statue, machine, bâtiment, annulation et contrôles créatif),
build complet et archives normal/Test vérifiés.
## CATALOGUE-099 — Progression et Métabli illustré
Branche `codex/catalogue-progression-beta099`.
[Contrat](catalogue-progression-beta099.md) : arbre des progrès intégré sous
les compétences, catalogue partagé entre catégories avec aperçus et trois
plans personnels terminés, palette étendue, K bref/maintenu et Métabli dans
les nouvelles salles souterraines. Anciennes pièces sauvegardées préservées.
## EXPLORATION-100 — Statues découvertes et promenade
Branche `codex/exploration-familiers-beta100`.
[Contrat](exploration-familiers-beta100.md) : catalogue de statues basé sur les
rencontres et statistiques serveur ; promenade avec pauses selon le tempérament,
sans orbite permanente ni concurrence avec les ordres et le combat.
EXPLORATION-100 vérifié : douze promenades, régression autonomie/ordres, catalogue
serveur, imports `.schem`, réouverture et deuxième monde ; build et archives
normal/Test réussis. Incident Windows non reproduit sans son journal.
## LANDING-101 — Atterrissage des familiers volants
Branche `codex/flying-landing-beta101`.
[Ticket](flying-landing-beta101.md) : supprimer le faux coup de chute des
profils volants, conserver les attaques réelles et les chutes terrestres.
LANDING-101 vérifié : reproduction native avant correctif, douze espèces
volantes, contacts lents/rapides, vraies attaques et chute terrestre ;
build complet et archives normal/Test contrôlés.
## beta.105 — Saisons et chapeaux vivants
Livré localement et vérifié en copie isolée : [contrat et contrôles](living-hats-seasons-beta105.md).
## EGGS-107 — Acquisition des œufs
Branche `codex/eggs-beta107`. [Contrat](spawn-eggs-acquisition-beta107.md) :
naissances en œufs et œuf du générateur sans Toucher de soie.
[Catalogue](spawn-eggs-catalogue-beta107.md) : 88 espèces, 35 voies couvertes,
53 propositions en attente de validation du créateur. Les rangs et Anomaly
restent hors de ce ticket. Les sources sont intégrées en préservant le chantier
Statuaire ; la validation binaire est isolée sur la dernière base livrée beta.105.
## SNOW-108 — Neige en volume
Branche `codex/snow-accumulation-beta108`.
[Contrat](snow-accumulation-beta108.md) : épaississement par précipitations,
blocs pleins à huit couches et accumulation verticale. Limite serveur de deux
blocs par défaut, réglable. Les blocs sont natifs, sans migration de terrain.
Livraison locale vérifiée : essais natifs dans deux mondes neufs, build complet
et archives normal/Test contrôlés ; assemblage isolé sur beta.107.
## SNOW-109 — Fonte saisonnière
Branche `codex/seasonal-snow-melt-beta109`.
[Contrat](seasonal-snow-melt-beta109.md) : fonte progressive par saison,
provenance des dépôts persistante par chunk, constructions préservées.
Livraison locale vérifiée sur beta.108 : essais natifs, rechargement de
chunk, build complet et archives normal/Test contrôlés.
## INTEGRATE-110 — Atelier dargile dans le pack complet
Branche `codex/clay-workshop-integration-beta110`.
[Ticket](clay-workshop-integration-beta110.md) : assemblage commun de
beta.106 et beta.109, avec tous les systèmes intermédiaires. Essais natifs de
latelier, des œufs et de la neige, build et archives réussis. Publication
et synchronisation de linstance Sanctuary Beta terminées ; 923 fichiers
personnels et réglages suivis conservés, aucun monde ouvert.
+91
View File
@@ -0,0 +1,91 @@
# CARRY-08 — Fuite des animaux volants portés — beta.114
Contrat du 17 septembre 2026, branche `codex/carried-flight-panic-beta114`.
Un coup reçu par un animal volant porté sur la tête déclenche une fuite de
40 à 60 ticks : il entraîne son porteur vers le haut, dans une direction
aléatoire, avec des embardées. Le serveur choisit le départ et la trajectoire,
transmis au client pour la physique native. Les touches de déplacement et le
regard ne pilotent pas cette brève fuite. La caméra reste libre. Un nouveau coup peut relancer la fuite avec un autre
cap après dix ticks, sans cumuler les vitesses.
Le porteur peut viser et frapper son animal volant. Les animaux ordinaires
reçoivent les dégâts habituels ; le familier conserve son coup amical sans
perte de santé. Les attaques extérieures acceptées déclenchent aussi la fuite.
Les autres protections entre passagers et porteurs restent en place.
Les espèces reprennent le groupe déjà utilisé pour planer, poule comprise.
La vitesse reste bornée, les collisions sont natives et aucun bloc nest
traversé ni déplacé. Déposer lanimal (Maj + clic droit), le perdre, mourir,
se déconnecter ou changer de dimension arrête la propulsion. Immersion,
mode spectateur, vol créatif, élytres et porteur lui-même passager linhibent.
La fin de fuite rend le contrôle au joueur et conserve le planeur existant
si lanimal reste porté. Les montures colossales gardent leur pilotage actuel.
L’état de fuite est temporaire, synchronisé et non sauvegardé. Aucun format
persistant, identifiant existant ou monde personnel nest modifié. Les nouveaux
retours dinterface sont traduits FR/EN. Client et serveur utilisent beta.114.
## Vérifications natives
`CarryPanic114ClientChecks` passe en **56 secondes** avec un serveur intégré
Minecraft 26.3 et un monde plat jetable de graine 114 :
- Véritable ciblage du perroquet sur la tête et frappe avec la touche native,
perte de santé de lanimal ordinaire, décollage et déplacement du joueur.
- Trajectoire synchronisée et priorité sur les touches avant/droite/saut et
le regard ; comparaison exacte avec la vitesse du tick de physique.
- Expiration, suppression de l’état temporaire et retour au planeur normal.
- 32 trajectoires tirées côté serveur couvrent les quatre quadrants, toutes
dans les bornes de durée et de vitesse ; Maj + clic droit natif interrompt.
- Sept espèces ordinaires : poule, chauve-souris, abeille, allay, perroquet,
blaze et breeze. Une vache portée reste protégée et ne déclenche aucun vol.
- Attaque extérieure, mort, activation du vol créatif et détachement arrêtent
ou déclenchent la réaction selon le contrat.
- Plafond bas : aucune traversée ; le passager bloqué est déposé par les
contrôles de place existants et la propulsion sinterrompt.
- Véritable coup amical sur un familier perroquet : lancement sans perte de
santé, puis arrêt immédiat lors du détachement.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryCarryPanic114ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Marqueurs `CARRY114_NATIVE_FLIGHT_PASS` et `CARRY114_PASS` dans
`build/carry-panic114-client.log`. Capture en jeu relue dans
`build/carry-panic114-evidence/`. Le GameTest dédié reste exclu conformément
au refus antérieur de son EULA. Pas de test Windows, de réseau distant ou de
latence simulée ; aucun monde personnel na été ouvert.
## Livraison
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 22 s**, 125 tâches. Les sources JAR correspondent exactement
aux sources du dépôt. Les classes et ressources hors de ce ticket restent
identiques à beta.113, notamment les sculptures dargile. Les deux packs
contiennent le même JAR, sans monde, GLB ni classes de test.
- Sanctuary-beta.114.mrpack : 10179428 octets, SHA-256
`92aac61c2c00a2206f3f5ac3d170ab058f6765526cf5a2a64c5336261db3f5bf`.
- Sanctuary-Test-beta.114.mrpack : 10198350 octets, SHA-256
`5e8ec1f18e34ca51e09e2738f2b04f9a1bbac5d14c7a7c2d766218a4cff6021f`.
- JAR Sanctuary : SHA-256
`18105b76f63008210c62d5728f999c7abc083a861ecc1de2c7ee87262dde5a40`.
Reçu local : `build/carry-panic114-artifact.json`.
## Publication et installation
La [release beta.114](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.114)
est publiée depuis `21845bd7367a260ee6a3d189417a8620aa5bf738`. Le tag exact
et les artefacts sont immuables ; les téléchargements publics sont vérifiés.
Le canal packwiz avance à `c26f3bb59491c9bb4bbdd27f59571ef84f417f7b`.
Deux synchronisations isolées puis deux passages dans **Sanctuary Beta** ont
réussi. Un seul JAR Sanctuary beta.114 est actif, et le second passage conserve
les mêmes hashes. Les **923 fichiers personnels et réglages suivis** restent
identiques. Aucun monde personnel na été ouvert. La sauvegarde ciblée est
`sanctuary-backups/before-beta.114/` dans linstance existante. Reçus locaux :
`build/carry-panic114-isolated.json` et `build/carry-panic114-prism.json`.
+127
View File
@@ -0,0 +1,127 @@
# beta.099 — Progression, catalogue du Métabli et gestes K
Contrat de réalisation, 16 septembre 2026. Branche `codex/catalogue-progression-beta099`.
- Compétences en haut de la colonne gauche, arbre des progrès Minecraft intégré
dessous ; aptitudes à droite. Prestige et historique sous larbre.
- Catalogue visuel natif : trois derniers plans terminés en accueil, statues,
machines, bâtiments, infrastructures, décoration et bibliothèque. Même format
de fiches (aperçu, nom, dimensions, description et sélection).
- Historique personnel local de trois plans terminés, sans autorité sur le monde :
aucun choix de plan nest compté comme construction et chaque placement reste
contrôlé par le serveur. Le stockage est distinct des sauvegardes Minecraft.
- Palette de statue étendue aux blocs ayant un objet utilisable, échantillonnée
depuis le pack actif. Pas de blocs techniques sans objet ni de commandes.
- Clé dorée : modèle plat doutil Minecraft. Texture provisoire vanilla, à
remplacer par la texture que le créateur fournira ; aucune texture dessinée
à sa place.
- K bref ancre au bloc visé ; rappuyer sur le même point tourne de 90 degrés.
K maintenu ouvre le cockpit. Viser le vide ne repositionne pas le projet.
## Contrat de génération et de compatibilité
Le module EXPANSION_HALL conserve sa géométrie, ses appuis, son piédestal et son
identifiant. Les nouvelles pièces natives de la salle indépendante portent un
champ optionnel `SanctuaryMetatable099=true` : neuf parties de Métabli assemblées
sont posées au centre, un bloc au-dessus du piédestal. Une pièce enregistrée sans
ce champ conserve son comportement ancien (aucun ajout), même si un de ses chunks
n’était pas encore décoré. Le champ ne modifie ni le journal ni la graine ni les
identifiants de génération. Aucune régénération ou écriture dans un monde
personnel existant. Les nouvelles salles générées après cette version reçoivent
lexemple ; les salles déjà enregistrées ne sont pas modifiées.
Une fois généré, le Métabli suit les règles normales : le casser le désassemble,
et le reconstruire exige la clé dorée. Pas de réparation automatique au chargement.
Vérification prévue sur nouvelles sauvegardes de développement et round-trip NBT
avec et sans le champ optionnel, génération en plusieurs ordres de chunks.
## Validation
Deux essais natifs sur Minecraft **26.3 finale**, nouveaux mondes de développement :
- `Catalogue099ClientChecks` : **réussi en 1 min 33 s**. Génération et construction
dune statue de Creeper de 32 blocs (1 542 cellules) avec la palette étendue ;
construction des plans Fourneau et Abri, confirmation/annulation, retrait du
créatif pendant la confirmation et refus dun envoi falsifié en survie. Pose
manuelle via le contrôleur Minecraft consommant exactement un bloc. Trois plans
terminés mémorisés, conservés au changement de monde, écrits sur disque et
décodés. Catalogue physique, catégories, aperçus, import/export, abandon et
destruction du Métabli ; K bref/rotation/visée vide/maintien. Arbre des progrès
sous les compétences, clic droit passant par l’écran et activant réellement le
suivi. Interfaces FR/EN, échelles 2 et 3, petite fenêtre et fenêtre 1280 × 800.
- `Temple097ClientChecks`, étendu à la salle : **réussi en 4 min 48 s**, graine
**-4700804240597771092**, génération racine `sanctuary:island_v24`. Origine des
neuf parties du Métabli : **(-56, 169, -117)**, sur le piédestal de pierre lisse.
Les neuf états `part=0..8` sont présents après génération puis après sauvegarde
et réouverture du monde de développement. Aller-retour NBT du nouveau champ ;
ancien tag sans champ restant sans champ. Régression des deux temples natifs,
coffres, pièges et cache de planification réussie.
Les captures dinterface vérifiées sont dans `build/catalog099-evidence/`.
Journaux : `build/catalog099-client.log` et `build/catalog099-hall.log`.
La capture de la salle prise immédiatement après téléportation précédait le
rendu des chunks et nest pas utilisée comme preuve visuelle ; les assertions
sur les blocs, appuis et sauvegardes constituent la validation de sa génération.
Les premiers passages ont détecté deux problèmes corrigés : les petits blocs
(fleurs) favorisés par une comparaison de couleur seule, et la protection native
des vestiges refusant lajout du Métabli hors de leur contexte de génération.
Des clics automatisés immédiatement après redimensionnement donnaient aussi un
rayon de caméra périmé : les interactions physiques répétées du test passent
maintenant par le contrôleur client Minecraft et son protocole normal, avec
contrôle serveur de la consommation et de louverture. Les gestes K et le suivi
des progrès restent testés par les événements dentrée natifs.
`check build assemblePack assembleTestPack` : **réussi en 3 min 7 s**, 124 tâches.
Le GameTest sur serveur dédié est exclu ; les tests client natifs ci-dessus
utilisent uniquement les mondes de développement.
Les deux exports packwiz sont vérifiés : version, sources Java, contenu du JAR,
absence des classes de test et conservation des archives beta.098. Reçu complet :
`build/beta099-artifact.json`.
| Archive | Octets | SHA-256 |
| --- | ---: | --- |
| `Sanctuary-beta.099.mrpack` | 9 908 941 | `9722e2410e18bba34cf5db835facec4bb29fc3bd7f071942e7c8321a60d955ed` |
| `Sanctuary-Test-beta.099.mrpack` | 9 927 866 | `7ce02718c2e4c780197f9984b1ac7bc7a443f78eaa3e7d8ea248aa6b2e75745a` |
Aucune publication du canal ni installation personnelle effectuée.
## Détails du catalogue
Les vignettes de plans utilisent les quads, UV et textures du pack actif.
Les blocs rendus par un moteur spécial sans quads (certains conteneurs) ont une
silhouette issue de leur forme. Les vignettes sont mises en cache (48 au maximum),
invalidées lors dun rechargement du pack. Les échantillons sont bornés à 64 × 64
par texture et prennent la première image des textures animées.
Les statues du catalogue affichent le modèle natif adulte ; le choix ouvre les
réglages de hauteur et de palette avant génération. Sur les petites fenêtres,
la description et les dimensions passent dans linfobulle pour garder le choix
visible sous la vignette.
La palette « Tous les blocs » parcourt les blocs enregistrés ayant un objet,
sans inclure les blocs techniques sans objet ou les blocs de commande. La
comparaison de couleur utilise Oklab ; un coût de forme favorise les volumes
pleins, et les blocs soumis à la gravité sont moins favorisés. Les feuilles
utilisent leur état persistant, comme après une pose par un joueur. Les palettes
mixte, laine et béton restent disponibles.
Lhistorique `schematics/sanctuary/recent-completed.nbt` contient au maximum
trois plans distincts, écrits atomiquement hors du fil de rendu. Il enregistre
une transition dincomplet à complet observée dans le monde, en créatif ou en
survie. Sélectionner un plan, lapercevoir ou lenvoyer au serveur ne suffit pas.
Il sagit dun historique personnel de cette installation, pas dun catalogue
partagé par le serveur. Aucun inventaire ou entité nest enregistré.
K bref utilise un rayon de 96 blocs sur les blocs du monde. La case adjacente à
la face visée sert de centre dancrage horizontal. Une seconde pression sur ce
même point tourne autour de cet ancrage ; déplacer le regard vers un autre
point déplace le plan. Le maintien de 10 ticks ouvre les commandes sans exécuter
le geste bref à la relâche. Les raccourcis remappés utilisent la même logique.
## Texture de la clé
Le modèle est `minecraft:item/handheld` ; la texture provisoire est une copie
de la pioche dorée vanilla. Le créateur pourra remplacer uniquement
`mods/sanctuary/src/main/resources/assets/sanctuary/textures/item/golden_wrench.png`
par son propre PNG. Aucune illustration personnalisée na été créée à sa place.
+40
View File
@@ -0,0 +1,40 @@
# beta.104 — Contraste des argiles grise et noire
Branche `codex/clay-contrast-beta104`, Minecraft 26.3.
Le gris devient plus soutenu et le noir plus sombre, pour distinguer clairement
les trois argiles gris clair/gris/noir. Les nuances du dessin vanilla sont
renforcées : facteur 1,35 au lieu de 0,85. Le mélange conserve davantage de la
couleur des briques fournies (80 % pour le gris, 90 % pour le noir).
Les textures de bloc et de boule dargile sont accordées, toujours en 16 × 16
avec les silhouettes et transparences vanilla. La recoloration exacte par
script reste la méthode choisie par le créateur. Les quatorze autres couleurs
et toutes les briques restent identiques. Le rangement beta.103 est conservé.
[Aperçu avant/après](../build/clay-contrast-beta104-preview.png).
Le générateur écrit désormais son aperçu au nom de la version courante,
pour préserver les aperçus historiques.
## Validation et livraison
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussi
en 2 min 22 s (124 tâches). Comparaison visuelle avant/après effectuée.
Pas de nouvelle session Minecraft pour cette modification de textures.
La vérification des archives confirme exactement quatre textures modifiées :
blocs et boules dargile gris/noir. Toutes les classes Java, recettes, modèles,
autres assets et anciennes archives beta.103 sont inchangés.
Les images conservent leur taille 16 × 16 et leur transparence pixel par pixel.
Le contraste mesuré augmente de plus de 40 % sur chacune des quatre textures.
Luminance moyenne des blocs : gris clair 168,4 ; gris 118,0 ; noir 68,4.
Sources, métadonnées et contenu des packs vérifiés ; `git diff --check` réussi.
Reçu : `build/beta104-artifact.json`.
- [Pack normal](../build/Sanctuary-beta.104.mrpack), SHA-256 :
`83f087cce41316eed620e9764ff0b643c3d5af40fa5b4cebdff5720740708631`.
- [Pack de test](../build/Sanctuary-Test-beta.104.mrpack), SHA-256 :
`adbaad161550ab80dcbc88c27d760e2f735eef9a69ba3f9e2e69bbd25e895147`.
Les GameTests dédiés restent exclus. Aucun monde, canal ou installation
personnelle modifié ; archives locales uniquement.
+90
View File
@@ -0,0 +1,90 @@
# STAT-04 — Sculptures dargile et accroche au bord — beta.113
Contrat du 17 septembre 2026, branche `codex/clay-sculpture-grid-beta113`.
Les miniatures de latelier deviennent des « sculptures dargile » / « Clay
sculptures ». Le nom du modèle reste dans leur infobulle. Les statues en blocs
du Métabli restent une catégorie distincte.
À la pose, le bord horizontal le plus proche du point visé accueille la
sculpture. Lautre axe est centré au pixel entier le plus proche, sur la grille
de 1/16 de bloc. Sur une face latérale, elle touche le bord du bloc support.
À égalité exacte entre deux bords, laxe Z départage ; au centre exact,
le bord côté joueur est retenu. Lorientation suit toujours le regard.
Le bouton Tourner prépare le modèle avant fabrication. Les marges horizontales vides du modèle ne créent pas de décalage au bord.
Tous les sommets restent
sur la grille entière, même avec des dimensions impaires et après rotation.
## Compatibilité préalable
La propriété native additive `anchor` du bloc `sanctuary:statuary` mémorise
`center`, `north`, `south`, `east` ou `west`. Un ancien état sans cette propriété
prend `center` : il reste centré, avec la correction visuelle du demi-voxel.
Le schéma 1, les voxels, noms de modèles et identifiants ne changent pas.
Il ny a aucun parcours ni réécriture de chunks existants. La sauvegarde native
conserve laccroche ; les rotations et miroirs de structures la transforment.
Casser et reposer choisit une nouvelle accroche sans modifier le modèle.
Les nouveaux objets emploient le nom traduit de leur type ; les noms déjà
enregistrés et les noms personnalisés restent conservés. La collision cubique
et la limite dune sculpture par bloc restent inchangées. Client et serveur
doivent utiliser ensemble beta.113.
## Vérifications natives
Le parcours `Statuary106ClientChecks`, étendu par `Sculpture113ClientChecks`,
passe en **1 min 25 s**, sur Minecraft 26.3 et un monde plat jetable de graine 106 :
- 5 120 combinaisons de largeur/profondeur, orientation et accroche : contrôle
de la géométrie émise, grille exacte de 1/16 et absence de dépassement du bloc.
- Modèles avec marges transparentes, repères colorés pour vérifier le sens du
rendu et égalité de la géométrie centrée entre lobjet et le bloc.
- Vrais clics de pose sur les quatre bords : orientation, bord côté serveur,
réception côté client, butin/repose et conservation après reconnexion.
- Contextes natifs de pose sur les faces latérales et coordonnées négatives.
- Rotation/miroir de chaque combinaison daccroche et dorientation ; lecture
des anciens états sans `anchor`, avec et sans `facing`.
- Noms français/anglais des objets fabriqués, menu aux échelles 2 et 3,
fabrication payée, Maj-clic limité à la hotbar et régression Métabli.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryStatuary106ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Marqueurs `STATUARY113_PASS` et `SCULPTURE113_GEOMETRY_PASS` dans
`build/clay-grid113-client.log`. Captures relues dans
`build/clay-grid113-evidence/`. Le GameTest dédié reste exclu conformément au
refus antérieur de son EULA ; ces essais utilisent le serveur intégré macOS.
Pas de validation Windows ni depuis deux ordinateurs. Aucun monde personnel
na été ouvert.
## Livraison
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **3 min 11 s**, 125 tâches. Les sources JAR correspondent exactement
aux sources du dépôt. Les classes et ressources hors du ticket sont identiques
à beta.112. Les deux packs contiennent le même JAR, sans monde, GLB ni test.
- Sanctuary-beta.113.mrpack : 10173807 octets, SHA-256
`ed082ff29a0e9a70fd6786bc024241b276cb516bc09fb5151d6cb324604429bb`.
- Sanctuary-Test-beta.113.mrpack : 10192733 octets, SHA-256
`387f54dc77884a9c9bccdcc1904778ca7f0b2fbda126754fdc49b10a492e4602`.
- JAR Sanctuary : SHA-256
`e242d9fd2edeede922a72a10e988e29c2b49dea52105cfc9fc8573154849ead3`.
Reçu local : `build/clay-grid113-artifact.json`.
## Publication et installation
La [release beta.113](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.113)
est publiée depuis `6166d0aa9eba3c3dd84b8b9f1501e3346231ac82`. Le tag exact
et les artefacts sont immuables ; les téléchargements publics sont vérifiés.
Le canal packwiz avance à `e4c16d517bfda4f2b688604cc61945a46a4513a9`.
Deux synchronisations isolées puis deux passages dans **Sanctuary Beta** ont
réussi. Un seul JAR Sanctuary beta.113 est actif, et le second passage conserve
les mêmes hashes. Les **923 fichiers personnels et réglages suivis** restent
identiques. Aucun monde personnel na été ouvert. La sauvegarde ciblée est
`sanctuary-backups/before-beta.113/` dans linstance existante. Reçus locaux :
`build/clay-grid113-isolated.json` et `build/clay-grid113-prism.json`.
+79
View File
@@ -0,0 +1,79 @@
# STAT-03 — Atelier dargile limité à la hotbar — beta.112
Contrat du 17 septembre 2026, branche `codex/clay-workshop-hotbar-beta112`.
Le menu utilise uniquement les neuf cases de la hotbar active du joueur,
avec les deux emplacements de fabrication. Les rangées de réserve, y compris
les extensions Sanctuary, ne sont ni ajoutées au menu ni utilisées par ses
transferts. La rangée choisie avant louverture reste la hotbar courante.
La touche Tab retrouve la navigation des boutons dans ce menu.
Le cadre nine-slice tient dans une hauteur fixe de 202 pixels dinterface.
Il ne grandit plus avec la capacité de linventaire. Le Maj-clic du résultat
cherche uniquement une place dans la hotbar ; sans place, il ne consomme pas
dargile. Les touches 19 restent utilisables, pas l’échange avec la seconde
main. À la fermeture, la restitution native des objets restants est conservée.
Les identifiants, sauvegardes, données de sculpture et orientations beta.111
restent inchangés. Aucun monde existant nest ouvert ou réécrit. Client et
serveur doivent utiliser ensemble beta.112 pour les nouveaux indices du menu
temporaire (11 cases au total).
## Vérifications natives
Le parcours `Statuary106ClientChecks`, étendu à beta.112, passe en **1 min 13 s**
sur Minecraft 26.3 avec serveur intégré et monde plat jetable de graine 106 :
- Menu FR/EN aux échelles 2 et 3 : hauteur fixe, neuf cases de hotbar native,
deux cases de fabrication et absence de panneau dinventaire évolutif.
- Fabrication par Maj-clic depuis la hotbar, avec de largile aussi présente
dans la réserve, lextension et la seconde main : ces stocks restent intacts.
- Rejet des indices de cases absents et de l’échange avec la seconde main.
- Hotbar remplie de neuf piles : Maj-clic sans fabrication ni consommation.
- Une seule place dans une pile de 63 statuaires : production dun seul objet,
coût dune argile, aucune statue transférée dans une case cachée.
- Restitution de largile restante à la fermeture, seize teintes, quatre
orientations, butin/repose et sauvegarde/reconnexion sans fichier GLB.
- Régression du Métabli et annulation dimport conservées.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryStatuary106ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Marqueur `STATUARY112_PASS` dans `build/clay-hotbar112-client.log` ; captures
relues dans `build/clay-hotbar112-evidence/`. Le GameTest dédié reste exclu
conformément au refus antérieur de son EULA. Pas de test Windows ni depuis
deux ordinateurs ; aucun monde personnel na été ouvert.
## Livraison vérifiée
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **3 min 6 s**, 125 tâches. Les sources JAR correspondent aux sources
Java du dépôt. Les classes et ressources hors de ce ticket sont identiques
à beta.111, notamment les données et le rendu orienté des statuaires.
Les deux packs contiennent le même JAR, sans monde, GLB ni classes de tests.
- Pack normal : 10 169 337 octets, SHA-256
`55723a5d7cb99e350ad8fe150e92e0567430b5261ca4685abd900be34068658f`.
- Pack Test : 10 188 262 octets, SHA-256
`987818bf817ad80fd055b97e84bf4775fca54820d7c3055d6fd4089dc7a5a3c0`.
- JAR : SHA-256
`9007a770bbe69db5846f93140d910514f10839cc34ec7c6315a9381d8850adaa`.
Reçu local : `build/clay-hotbar112-artifact.json`.
## Publication et installation
La [release beta.112](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.112)
est publiée depuis `1eaa976ecf328c6c20afe5e4eb67c48118f87f95`. Le tag exact
et les artefacts sont immuables ; les téléchargements publics sont vérifiés.
Le canal packwiz avance à `573c5b0d84fcb3e9fec7cc8a83981cf35eaa1ead`.
Deux synchronisations isolées puis deux passages dans **Sanctuary Beta** ont
réussi. Un seul JAR Sanctuary beta.112 est actif, et le second passage conserve
les mêmes hashes. Les **923 fichiers personnels et réglages suivis** restent
identiques. Aucun monde personnel na été ouvert. La sauvegarde ciblée est
`sanctuary-backups/before-beta.112/` dans linstance existante. Reçus locaux :
`build/clay-hotbar112-isolated.json` et `build/clay-hotbar112-prism.json`.
+128
View File
@@ -0,0 +1,128 @@
# INTEGRATE-110 — Atelier d'argile dans la version complète
Demande du 17 septembre 2026 : inclure le Clay Workshop dans la version avec
la fonte saisonnière. Branche `codex/clay-workshop-integration-beta110`.
État : version commune vérifiée, publiée et synchronisée dans Sanctuary Beta.
## Périmètre et compatibilité
La beta.110 réunit le Clay Workshop / atelier d'argile, le Statuaire et l'import
GLB de beta.106 avec les œufs de beta.107, la neige en volume de beta.108 et
la fonte saisonnière de beta.109. Aucune fonction retirée de ces livraisons.
Le Métabli conserve son import de modèles 3D en plans de construction.
Les sources du dossier principal correspondent déjà à cette union : les
30 fichiers de production du Statuaire sont ceux de la livraison beta.106 ;
les traductions FR/EN et listes de mixins sont la réunion exacte des deux bases,
sans conflit de valeur ni doublon. Les autres sources restent celles de beta.109.
Les fichiers Finder `.DS_Store` sont exclus par la configuration Gradle existante.
Les identifiants et contrats de données restent ceux des tickets existants :
[Statuaire](statuary-beta106.md), [œufs](spawn-eggs-acquisition-beta107.md),
[accumulation](snow-accumulation-beta108.md),
[fonte](seasonal-snow-melt-beta109.md). Aucun format changé, aucune migration,
aucune génération ou expansion activée. Les tests créent des mondes de
laboratoire et rouvrent seulement leurs propres sauvegardes jetables.
Les compteurs `mod_version`, `pack_version` et le manifeste packwiz passent
ensemble à beta.110. Les archives beta.106 à beta.109 restent immuables.
Le créateur a ensuite demandé de tout mettre à jour : publication de la
version vérifiée sur le canal stable puis synchronisation de linstance
Sanctuary Beta existante, après sauvegarde des fichiers gérés.
## Validation exécutée
Réutilisation des essais natifs de l'atelier (fabrication, rendu, Métabli et
sauvegarde/rechargement), des œufs (reproduction, éclosion et générateurs), et
de la neige (accumulation et fonte selon les saisons, provenance et rechargement)
sur les mêmes sources réunies. Puis `check build assemblePack assembleTestPack`.
Le GameTest dédié reste exclu conformément au refus antérieur de son EULA ;
les tests natifs tournent avec le serveur intégré.
## Contrat de mise à jour de l'instance existante
Cible unique : `Sanctuary-0.1.0-alpha.1`, nom affiché **Sanctuary Beta**, déjà
reliée au canal `sanctuary-beta/packwiz`. Les instances historiques 26.2 et les
instances importées séparément restent hors de cette synchronisation.
Minecraft 26.3-pre-2 passe à 26.3 finale, Fabric Loader reste en 0.19.5,
Fabric API passe de 0.160.0+26.3 à 0.160.5+26.3 ; Java 25 est déjà configuré.
L'installateur packwiz est chargé de remplacer les deux JAR suivis et d'ajuster
les composants de lancement. Sauvegarde préalable des JAR gérés, du suivi
packwiz, de `instance.cfg` et de `mmc-pack.json` hors de `mods/`.
Aucun monde personnel ne sera ouvert, converti, régénéré ou exploré pendant
l'opération. Les fichiers de sauvegarde, réglages, captures et packs personnels
sont comparés par empreintes avant/après. Seuls les composants gérés du pack et
la version du lanceur changent. Les anciennes règles de génération enregistrées
dans les mondes restent intactes ; aucune promesse de migration automatique de
sauvegarde entre versions Minecraft n'est déduite de la mise à jour des fichiers.
Le canal est publié avec un artefact immuable et testé deux fois dans une
installation isolée avant les deux synchronisations de l'instance existante.
## Résultats de la version commune
Tous les parcours sont exécutés sur les mêmes sources beta.110, sous Java 25,
Minecraft 26.3 et Fabric API 0.160.5+26.3 :
- Atelier d'argile : **1 min 6 s**, `STATUARY106_PASS` et import Khronos indépendant.
Fabrication payante, 16 argiles, placement, butin, Métabli et rechargement sans
le fichier source. Captures relues dans `build/integration110-evidence/`.
- Œufs : **29 s**, `EGGS107_NATIVE_PASS` ; reproduction, variantes, œufs utilisés
et distribués, générateurs et monde sans règles Sanctuary.
- Neige : **42 s**, `SNOW108_NATIVE_PASS` et `MELT109_NATIVE_PASS` ; limite de
deux blocs, saisons, protection des constructions et sauvegarde/rechargement.
- `./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest` :
**2 min 20 s**, **125 tâches**, réussite.
Le JAR est comparé à l'union exacte des classes et ressources des deux livraisons
immuables beta.106 et beta.109. Les traductions et mixins réunissent tous les
éléments, sans doublon. Les sources JAR correspondent aux fichiers Java locaux.
Aucun GLB de test, classe de test, monde ou fichier Finder embarqué.
[Pack normal](../build/Sanctuary-beta.110.mrpack) ·
[Pack Test](../build/Sanctuary-Test-beta.110.mrpack).
| Artefact | SHA-256 |
| --- | --- |
| Normal | `12a7b7279e831bf5d3489d033b875437ad9e8ba1e8cbecc6107a890d312e3029` |
| Test | `ffed5e4fcd32ca32ac96186da8e987905cd7fc4de792afe61aca9ad064895e0b` |
| JAR Sanctuary | `7cb121a6e208cdd184d80ebe61f8bc52b6703b1c1d7611eeeb700aa88d158b46` |
Reçu : `build/integration110-artifact.json`, manifeste des sources :
`build/integration110-source-manifest.json`. Journaux :
`build/integration110-{statuary,eggs,snow,check-build}.log`.
Les essais natifs sont macOS avec serveur intégré ; pas de test Windows ni
connexion depuis un second ordinateur. La neige antérieure non suivie garde
la limite de fonte documentée dans beta.109.
Le script de publication a été corrigé pour autoriser uniquement `icon.png`
en plus des manifestes TOML, après contrôle de l'index et comparaison à l'icône
source. Les JAR restent exclusivement des pièces jointes de release. Cette
correction de distribution ne change aucun binaire ni le tag source beta.110.
## Publication et installation terminées
[Release beta.110](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.110),
tag source `468743bfe75087ecb0160b85a0864fa81bcdded5`. Les sources cumulatives
depuis beta.092 sont enregistrées ; les anciens tags et artefacts sont conservés.
Les corrections du script de distribution et ce reçu complètent la branche
source sans déplacer ce tag ni remplacer les binaires.
Canal packwiz : `828353a6e2bd747f4f473ae9fb9e965d38032419`. Chaque manifeste et
l'icône publiés correspondent aux fichiers validés localement. JAR, archives
normale/Test et ZIP d'amorçage Prism sont disponibles dans la release.
Deux synchronisations de l'installateur packwiz dans un dossier neuf, puis deux
dans la même instance **Sanctuary Beta**, ont réussi. Le second passage ne
change aucun fichier géré. Un seul JAR Sanctuary beta.110 est actif, Fabric API
0.160.5+26.3 est installé, Minecraft est réglé sur 26.3 et Loader sur 0.19.5.
L'icône déclarée dans le pack est ajoutée. `instance.cfg` et les **923 fichiers
personnels et réglages suivis** conservent leurs empreintes. Aucun monde ouvert,
converti ou régénéré ; aucune autre instance ni serveur personnel modifié.
Sauvegarde ciblée dans l'instance : `sanctuary-backups/before-beta.110/`.
Reçus ignorés : `build/integration110-publication.json`,
`build/integration110-isolated.json`, `build/integration110-prism.json`.
Les journaux de chaque synchronisation et les listes d'empreintes avant/après
sont conservés dans le dossier de développement et dans la sauvegarde ciblée.
+99
View File
@@ -0,0 +1,99 @@
# STAT-02 — Atelier dargile et orientation — beta.111
Contrat du 17 septembre 2026, branche `codex/clay-workshop-ui-beta111`.
## Résultat attendu
Le menu utilise le cadre `task_frame_unobtained.png` fourni par le créateur,
sans retouche, comme sprite Sanctuary avec les métadonnées nine-slice natives
de Minecraft. Les coins restent à leur taille dorigine. Catalogue, aperçu,
fabrication, explications et inventaire évolutif tiennent dans ce cadre.
La texture reste remplaçable par un pack de ressources.
La pose dun statuaire suit le regard horizontal du joueur, dans les quatre
directions. Le bouton Tourner règle toujours lorientation du modèle importé.
Le placement ajoute ensuite lorientation du bloc sans réécrire ses voxels.
## Contrat de compatibilité préalable
Ajout de la propriété native `facing` au bloc `sanctuary:statuary`, avec `south`
par défaut : cette orientation correspond au rendu historique, sans rotation.
Les anciens états sans propriété prennent cette valeur à la lecture et gardent
leur apparence. Aucun parcours ni réécriture des anciens chunks nest effectué.
Le schéma 1 de la sculpture, ses voxels, ses identifiants et les données des
objets restent identiques. Casser puis reposer utilise le nouveau regard,
sans cumuler les orientations précédentes. Rotation et miroir de structures
transforment l’état du bloc. La collision cubique reste celle déjà livrée.
## Texture
Le fichier fourni, 26 × 26 pixels, est conservé octet pour octet sous
`assets/sanctuary/textures/gui/sprites/container/clay_workshop/frame.png`.
Le fichier adjacent `.png.mcmeta` utilise le moteur `nine_slice` de Minecraft
avec une bordure fixe de quatre pixels et un centre extensible. Lidentifiant
du sprite est `sanctuary:container/clay_workshop/frame` ; aucun sprite global
Minecraft nest remplacé. Les emplacements utilisent `minecraft:container/slot`.
## Vérifications natives
`Statuary106ClientChecks`, étendu aux cas beta.111, passe en **1 min 3 s** sur
Minecraft 26.3, avec serveur intégré et monde plat jetable de graine 106 :
- Fabrication payée, restitutions, Maj-clic et teintes des seize argiles.
- Menu FR/EN aux échelles 2 et 3, cases et actions contenues dans le cadre.
- Six rangées dinventaire à l’échelle 3 : affichage borné et défilement.
- Vrais clics de pose aux quatre points cardinaux : état côté serveur,
transmission au client et rotation du rendu alignés sur le regard.
- Rotation et miroir de structures ; lecture dun ancien état sans `facing`.
- Butin dune statue orientée au nord puis repose vers louest : nouvelle
orientation, mêmes voxels. Sauvegarde/réouverture et transmission sans GLB.
- Conversion au Métabli et annulation dimport conservées.
Commande :
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryStatuary106ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Marqueurs `STATUARY106_PASS`, `STATUARY106_INTEROP_PASS` et `STATUARY111_PASS`
dans `build/clay-ui111-client.log`. Captures dans `build/clay-ui111-evidence/`,
dont les menus français et anglais relus visuellement.
Le GameTest dédié reste exclu conformément au refus antérieur de son EULA.
Les essais tournent sur macOS avec serveur intégré ; pas de validation depuis
deux ordinateurs ni sur Windows. Aucun monde personnel nest ouvert.
## Livraison vérifiée
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 33 s**, 125 tâches. Les sources JAR correspondent exactement
aux fichiers Java du dépôt. Le contenu du mod hors classes et ressources de
ce ticket est identique à beta.110 ; œufs, neige et fonte restent inclus.
Les deux MRpack contiennent le même JAR vérifié, sans monde, GLB ni classe de test.
- Pack normal : `build/Sanctuary-beta.111.mrpack`, 10 169 372 octets,
SHA-256 `f83098f80ac2edb8e32877bf8d5a362cd75f2fa9020593c2c7e4f9abe8b3e9f6`.
- Pack Test : `build/Sanctuary-Test-beta.111.mrpack`, 10 188 298 octets,
SHA-256 `1234b10eb4685d99aa7421de46bc54fd114b791f1b4d63803a09afc926d2c9d9`.
- JAR Sanctuary : SHA-256
`b3e967db7b336b970e4121981e3ef8140fe90f0ecc5850ce90a70caaf3772684`.
Reçu local : `build/clay-ui111-artifact.json`.
## Publication et installation
La [release beta.111](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.111)
est publiée depuis le commit source `289697becf19db4acf50d224b1dd3d753eff1153`.
Le tag exact et les artefacts sont immuables ; leurs téléchargements publics
ont été vérifiés. Le canal packwiz avance au commit
`87c9f81761309316c601ed32fa922c1a5893952c`.
Deux synchronisations isolées puis deux passages dans linstance existante
**Sanctuary Beta** ont réussi. Un seul JAR Sanctuary beta.111 est actif ; le
second passage conserve les mêmes hashes. Les **923 fichiers personnels et
réglages suivis** restent identiques. Aucun monde personnel na été ouvert.
La sauvegarde ciblée est dans `sanctuary-backups/before-beta.111/` de cette
instance. Reçus : `build/clay-ui111-isolated.json` et
`build/clay-ui111-prism.json`.
+9
View File
@@ -249,6 +249,15 @@ Identifiant proposé : `sanctuary:clay_and_bricks`. **10 recettes primaires.**
**Précision :** Le vase décoré appartient à Archéologie ; la terre cuite à C11.
**Extension Sanctuary beta.102 :** seize couleurs de briques, dalles, escaliers,
argile pastel et leurs deux items, soit 192 recettes supplémentaires. Elles
sont déclarées dans `scripts/data/collections-sanctuary.json`, sans changer
le recensement vanilla ci-dessus.
**Extension Sanctuary beta.106 :** atelier dargile et statuaires importés. La
recette de latelier rejoint C06 ; la fabrication dune miniature utilise son
menu et un bloc dargile, avec le modèle choisi localement.
[Recettes et inventaire exacts de C06](collections-minecraft-inventaire.md#c06).
[Définition serveur beta.035](../mods/sanctuary/src/main/resources/data/sanctuary/sanctuary_recipe_collections/clay_and_bricks.json).
+83
View File
@@ -0,0 +1,83 @@
# beta.118 — Murets en briques de couleur
Ticket sur `codex/colored-brick-walls-beta118`, Minecraft 26.3.
Les seize familles de briques reçoivent un muret natif `WallBlock`, sous
l'identifiant `sanctuary:<couleur>_brick_wall`. Chaque muret réutilise la texture
de briques fournie de sa couleur, sans nouvelle image ni recoloration.
Les murets forment une série de seize dans **Blocs colorés**, après les dalles
et avant les argiles, dans l'ordre des couleurs vanilla retenu en beta.103.
Les libellés sont traduits FR/EN.
Recettes : six briques de même couleur, en deux rangées de trois, donnent six
murets ; le tailleur de pierre donne un muret par bloc de briques. Les deux
recettes par couleur rejoignent la collection Argile et briques et leurs
déblocages. Totaux des ajouts colorés : 80 blocs, 112 objets dont ces blocs,
224 recettes. Les recettes et identifiants préexistants sont conservés.
Piliers, raccords entre couleurs et aux murets vanilla, côtés hauts/bas,
collisions et immersion dans l'eau viennent du jeu. Tags natifs `walls`
pour blocs et objets, pioche et butin de la bonne couleur. États, modèles
multipart et butins sont repris des ressources exactes Minecraft 26.3.
La génération reste reproductible dans `tools/generate-colored-bricks.py` ;
elle conserve les ajouts externes à ses briques, notamment la recette du
Clay Workshop et les membres Sculpture/Atelier. Le compilateur des collections
reste leur seul générateur. Aucun ajout au
terrain, changement de format ou migration de sauvegarde. Les dépendances
et le pack de textures intégré beta.090 restent inchangés.
## Vérifications et livraison
Le scénario natif `Bricks102ClientChecks`, complété par `Walls118Checks`,
passe en **38 secondes** sur un nouveau monde plat de graine 102 avec serveur
intégré Minecraft 26.3 :
- Seize murets réellement posés, dont une seconde série dans l'eau.
- Recette de six murets et tailleur de pierre pour chaque couleur ; les
anciennes recettes de briques, dalles, escaliers et argiles passent aussi.
- Raccords entre couleurs et au muret vanilla, retrait des piliers intermédiaires,
côtés relevés sous un bloc plein, collisions identiques au muret vanilla.
- Eau conservée, pioche, butin de la bonne couleur, collection et recettes
découvertes, recette du Clay Workshop toujours découverte, série créative
de seize dans l'ordre natif.
- États et modèles de tous les blocs de la famille vérifiés, sans texture
manquante ; sauvegarde et reconnexion conservent les murets et leur eau.
Log : `build/walls118-client.log`, marqueur `WALLS118_PASS`. Capture de la
galerie relue dans `build/walls118-evidence/`. Aucun essai Windows ou LAN ;
GameTest dédié exclu selon le refus antérieur de son EULA. Aucun monde personnel
ouvert. La régénération finale conserve à l'identique 2 024 fichiers de
ressources et métadonnées.
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussit
en **2 min 5 s**, 125 tâches dont 101 exécutées. Sources et JAR concordants,
intégrité ZIP, versions, modèles natifs, recettes et empreintes vérifiés.
La comparaison avec beta.117 limite les changements de classes à
`ColoredBricks` et ses classes internes ; les sculptures et chapeaux restent
identiques. Les textures existantes sont toutes conservées octet pour octet.
- `Sanctuary-beta.118.mrpack` : 10216437 octets, SHA-256
`75bb04aad981644888690aaeda9a293a94c5d32f6439ca24613365a41aba1b6b`.
- `Sanctuary-Test-beta.118.mrpack` : 10235359 octets, SHA-256
`f7f0a177b8472f0e8c34ad99a1ccef5d61396014017d260e934e910c4e6eb00f`.
- JAR Sanctuary :
`fc6edf4046a8ace357d561ed942213a22c7a27164fa160cdb4316f72ec302575`.
Reçu : `build/walls118-artifact.json`.
## Publication et instance
La [release beta.118](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.118)
est publiée : JAR, MRpack normal/Test et amorçage Prism, téléchargements publics
et empreintes vérifiés. Le tag immuable désigne
`0a81b3c1471c43d705eec4e31a8f6f1820049ca8`. Le canal packwiz avance au commit
`e6bab433bea9528f67d39af0d6c7bcb6443e4817`.
Deux synchronisations isolées puis deux dans l'unique instance **Sanctuary
Beta** réussissent, la seconde passe laissant les fichiers gérés identiques.
Un seul JAR Sanctuary beta.118 est actif. Les 923 fichiers personnels et
réglages suivis sont inchangés ; aucun monde personnel n'a été ouvert.
Copie préalable des fichiers remplacés dans `sanctuary-backups/before-beta.118/`.
Reçus : `build/walls118-isolated.json`, `build/walls118-prism.json`.
+73
View File
@@ -0,0 +1,73 @@
# beta.102 — Briques et argile de couleur
Branche `codex/colored-bricks-beta102`, Minecraft 26.3.
## Contrat
Les seize PNG de briques fournis sont présents. Ils sont intégrés sans altération.
Par couleur : bloc de briques, dalle, escalier, bloc dargile pastel, boule dargile
et brique (items). Les 64 blocs apparaissent dans longlet natif des blocs colorés,
les 32 items dans les ingrédients. Les composants ont des identifiants stables
`sanctuary:<couleur>_*` et des libellés français et anglais.
La recoloration exacte par script a été choisie explicitement par le créateur.
Les silhouettes, la transparence et les nuances des textures vanilla 16 × 16
sont conservées ; les couleurs proviennent des PNG fournis. Aucun dessin IA.
Recettes natives : teinture de largile et des briques, assemblage des blocs,
cuisson de largile en briques, argile colorée en terre cuite vanilla de même
couleur, fabrication des dalles/escaliers et tailleur de pierre. Les recettes
rejoignent la collection existante argile/briques et les découvertes Sanctuary.
Dalles doubles, escaliers connectés, orientation, eau, outils et butin utilisent
les mécanismes natifs. Les blocs ne sont pas ajoutés à la génération du terrain.
Aucune migration de sauvegarde et aucune modification dun monde existant.
## Génération reproductible
`tools/generate-colored-bricks.py --minecraft-jar <minecraft-client.jar 26.3>`
requiert Python avec Pillow. Les briques sources sont conservées dans les assets
et les couleurs/empreintes sont consignées dans `tools/colored-bricks-palette.json`.
Les extensions de collection sont compilées par `scripts/compile_collections.py`
depuis `scripts/data/collections-sanctuary.json` ; le recensement vanilla reste intact.
## Validation native
`Bricks102ClientChecks` sur Minecraft 26.3, monde plat neuf avec serveur intégré :
- 16 familles, 64 blocs et 96 entrées ditems (dont 64 BlockItems).
- 192 recettes chargées ; fabrications, teintures, cuissons et tailleur exécutés.
- Butin des briques et escaliers ; dalle simple/double ; argile normale et Toucher de soie.
- Tags pioche/pelle et états immergés des dalles/escaliers.
- Onglets natifs : 64 blocs colorés, 32 ingrédients.
- Tous les états des 64 blocs possèdent des modèles cuits sans texture manquante.
- Découverte de largile : la collection ajoute les nouvelles recettes.
- Sauvegarde/réouverture du monde neuf : blocs et état de dalle haute conservés.
Parcours final réussi en 41 s, `build/bricks102-client.log`.
[Aperçu des textures](../build/colored-bricks-beta102-preview.png) ·
[Capture Minecraft](../build/bricks102-game.png).
La vérification du rendu utilise le client natif local. Aucun test multijoueur
distant nest revendiqué, aucune sauvegarde personnelle ni installation modifiée.
## Livraison
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussi
en 2 min 19 s (124 tâches). Les GameTests du serveur dédié restent exclus ;
le parcours client/serveur intégré décrit ci-dessus a été exécuté séparément.
`git diff --check` et la régénération identique octet pour octet sont vérifiés.
Les archives, sources, 192 recettes, 192 progrès de recettes, 96 libellés FR/EN,
les seize PNG fournis et les transparences vanilla ont été contrôlés.
La comparaison avec beta.101 ne trouve aucune autre classe de jeu modifiée
que linitialisation Sanctuary et les trois nouvelles classes ColoredBricks.
Les archives beta.101 conservent leurs empreintes.
Reçu : `build/beta102-artifact.json`.
- [Pack normal](../build/Sanctuary-beta.102.mrpack), 10 052 986 octets.
SHA-256 : `f324db41e9992df0635d870a766ec9c486167d7fc2e1ed5080f05deaf824d3c4`.
- [Pack de test](../build/Sanctuary-Test-beta.102.mrpack), 10 071 908 octets.
SHA-256 : `bf6b4a207f6b952295c01d59e788eb742d7dbee18ff5e0ad1e61ba78078348fb`.
Livraison locale, sans publication de canal, tag ou déploiement Prism.
+33
View File
@@ -0,0 +1,33 @@
# beta.103 — Rangement des blocs colorés
Branche `codex/colored-bricks-order-beta103`, Minecraft 26.3.
Dans Blocs colorés, les ajouts sont regroupés par type : les 16 blocs de briques,
puis les 16 escaliers, les 16 dalles, les 16 blocs dargile. Dans Ingrédients :
les 16 boules dargile, puis les 16 briques.
Chaque série suit lordre de couleurs de longlet natif Minecraft 26.3,
vérifié dans `CreativeModeTabs.bootstrap` : blanc, gris clair, gris, noir,
marron, rouge, orange, jaune, vert clair, vert, cyan, bleu clair, bleu, violet,
magenta, rose. Lordre denregistrement des blocs/items reste stable.
Les identifiants, recettes, textures et sauvegardes sont inchangés.
## Validation et livraison
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussi
en 2 min 41 s, 124 tâches. `git diff --check` réussi. Pas de nouveau test
ni de nouvelle session graphique pour ce changement limité au rangement.
Les sources et les deux archives ont été vérifiées. Les textures, recettes,
modèles et autres données sont identiques à beta.102, dont les archives sont
conservées. Seul le comportement de `ColoredBricks` change ; la classe interne
Stairs conserve exactement son code exécutable (seuls les numéros de lignes
de débogage changent). Reçu : `build/beta103-artifact.json`.
- [Pack normal](../build/Sanctuary-beta.103.mrpack), SHA-256 :
`5c0b475410c7586275a59a8b14ac1770648f8196983e62c85a98dcd0fa5a1207`.
- [Pack de test](../build/Sanctuary-Test-beta.103.mrpack), SHA-256 :
`222765733711a08861e25db94ae12a4a6483bc11dbfdcaf54930786c10511d0e`.
Les GameTests du serveur dédié restent exclus. Aucun monde personnel,
aucune installation ni aucun canal de distribution modifié.
+74
View File
@@ -0,0 +1,74 @@
# beta.098 — Construire le plan en créatif
Ticket CONSTRUCTION-098, branche `codex/creative-construction-beta098`.
À la demande du créateur, le parcours manuel du Métabli est complété par une
construction immédiate réservée au mode créatif.
## Parcours
Choisir une statue, une machine, un bâtiment ou un plan importé au Métabli.
Positionner et orienter laperçu avec K. Le bouton **Construire le plan…**
apparaît sous les coordonnées en créatif seulement. La confirmation indique
le nombre de blocs, lorigine et le fait que le plan entier sera construit,
même si une seule couche est affichée. Annuler revient aux réglages.
Confirmer pose les blocs sans consommer de matériaux, via le protocole serveur
existant. Le plan garde son origine, son orientation et son suivi davancement.
Les machines reçoivent leurs composants natifs : la clé dorée reste nécessaire
pour assembler le multibloc fonctionnel. Les inventaires et entités ne sont
pas copiés depuis les fichiers de plans.
Le bouton disparaît si le mode de jeu change. Une confirmation devenue périmée
(changement de plan, position, monde ou perte du créatif) ne lance rien.
Lenvoi en cours désactive le bouton et ne peut pas être remplacé par un second.
En survie, la construction reste manuelle et le serveur refuse toujours les
envois fabriqués sans passer par linterface.
Les validations serveur existantes restent applicables : cases libres ou déjà
conformes, zone chargée, hauteur et bordure du monde, distance de 192 blocs,
absence dentité à lemplacement, permissions et interdiction des blocs
techniques sans objet. Aucun changement de génération ni format de sauvegarde.
## Vérifications
Suite native `Plans072ClientChecks`, Minecraft 26.3, nouveau monde plat de
développement, graine 72 : **réussie en 47 secondes**.
- Bouton créatif visible et confirmation inspectés en capture native.
- Annulation sans placement ; statue de Creeper de 32 blocs de haut, 1 542
cellules, construite avec vérification de chaque matériau côté serveur.
- Fourneau et abri choisis dans le catalogue physique, rotation, filtre sur
une seule couche : le bouton construit bien toutes les couches du plan.
- Perte du créatif pendant la confirmation : aucun bloc posé, bouton absent.
- Envoi falsifié en survie refusé ; pose manuelle consommant exactement un bloc.
- Régression des 88 modèles natifs, export, matériaux et interfaces FR/EN.
Le premier passage enchaînait les plans en moins dune seconde et rencontrait
la limite denvoi serveur existante. Le test attend désormais 25 ticks entre
constructions ; aucun changement de cette limite na été nécessaire.
Le test Métabli conserve son assertion dabsence de catalogue à distance,
mais ne considère plus le nouveau bouton créatif comme interdit.
Captures dans `build/creative098-evidence/`, journal
`build/creative098-client.log`.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussi en **2 min 7 s**, 124 tâches. Les GameTests sur serveur dédié restent
exclus conformément au refus antérieur daccepter son EULA. Aucun serveur
dédié lancé ; essais uniquement sur un nouveau monde client de développement.
Les archives ont été vérifiées : version Minecraft 26.3 et compteur beta.098,
JAR sources identiques aux fichiers de travail, aucun test embarqué. Par
rapport à beta.097, seules les classes PlanClient/PlansScreen et trois libellés
FR/EN changent dans Sanctuary. Textures, données, resource pack et archives
beta.097 conservés. Reçu : `build/beta098-artifact.json`.
## Livraison locale
- `build/Sanctuary-beta.098.mrpack`, 9 880 395 octets ; SHA-256
`f06d0cc0256c6c55b1081019d0f00d2f9d29963cbf06d63b8cf1d9b303970f7a`.
- `build/Sanctuary-Test-beta.098.mrpack`, 9 899 316 octets ; SHA-256
`f2424a39b6b96076bdff8ac2a93e74ce83d77f453aa9bfbdd18a5fa17444f573`.
Aucun déploiement dans une instance personnelle, aucune sauvegarde personnelle
ouverte ou modifiée, aucun avancement du canal packwiz.
+5
View File
@@ -29,6 +29,11 @@ et ses règles restent versionnées pour éviter de déplacer un site au recharg
## Placement des temples
**Évolution beta.097** : le refus bloquant décrit dans cette version historique
est remplacé par une recherche de secours, puis un placement garanti des pièces
vanilla avec fondation si nécessaire. Les sites historiques admissibles restent
prioritaires. Voir le [contrat du correctif](temple-crash-beta097.md).
Les candidats sont des chunks de lintérieur de chaque île, à au moins 48 blocs
de son centre, ordonnés par un tirage déterministe. La recherche filtre le type
voulu, puis conserve les conditions natives de biome, de pente et de fondation.
+85
View File
@@ -0,0 +1,85 @@
# beta.100 — Statues découvertes et promenade des familiers
Branche `codex/exploration-familiers-beta100`. Minecraft 26.3.
## Contrat
Le catalogue des statues et son sélecteur ne proposent que les espèces
observées, tuées ou ayant tué le joueur. Le Métabli envoie une liste calculée
par le serveur à partir des notes `seen_mobs` et des statistiques natives.
Le champ didentifiant et la capture de cible respectent aussi cette liste.
Les plans déjà construits/importés restent des plans de blocs ordinaires.
Aucun nouveau format de sauvegarde, aucune migration ni changement de terrain.
Les familiers au repos choisissent une destination atteignable puis font une
pause. Le tempérament existant règle leur rayon, leur allure et leurs pauses :
curieux explorateur, joueur plus vif, calme plus posé. Les protecteurs restent
près du point gardé. Les déplacements dattaque, la fuite, les ordres, le portage
et les montures gardent la priorité. Les volants ne suivent plus une orbite
perpétuellement recalculée. Les chemins ratés sont abandonnés et les recherches
restent bornées dans les chunks chargés. Les identités et caractères existants
ne sont pas retirés au sort.
## Validation
`Exploration100ClientChecks` : **réussi en 4 min 37 s**. Nouveau monde plat
avec serveur intégré, sans serveur dédié :
- Une vache réellement visée débloque sa statue. Les statistiques natives
tué/par-qui-tué ajoutent zombie et squelette. Un œuf de cochon porté ne suffit
pas. Le client reçoit la liste du serveur ; une génération de cochon reste
refusée même en créatif. Capture du catalogue inspectée.
- Douze promenades réelles : loup, poule, slime, chauve-souris, chacun calme,
curieux et joueur. Plusieurs cases traversées, pauses, maintien près du joueur.
Le tempérament calme est testé avec personnalité protectrice, les deux autres
avec pacifiste pour isoler le déplacement au repos.
- Régression `Autonomy074ClientChecks` complète : quatre personnalités,
déplacements et dégâts réels, garde, cibles neutres, permissions, obstacles,
mode travail, ordres H bref/maintenu et touche reconfigurée.
- `.schem` Sponge v2/v3 : renommage natif ancien `grass` vers `short_grass`,
conservation exacte du fichier source et refus explicite dun bloc de mod absent.
Journal : `build/exploration100-client.log`. Captures :
`build/exploration100-evidence/`. La matrice ne constitue pas un nouveau playtest
visuel de toutes les espèces, ni une validation à plusieurs clients distants.
`Schematics100ClientChecks` : **réussi en 1 min 2 s**. Le client refuse un
fichier incompatible dans le Métabli sans fermer le menu, affiche son identifiant
fautif, puis importe un `.schem` valide. Fermeture, réouverture de la même
sauvegarde et ouverture dun deuxième monde passent, avec conservation de la
structure et remise à zéro du plan/de la session datelier. Le fichier rejeté
reste identique. Journal : `build/schematics100-client.log`.
Ces essais macOS ne reproduisent pas le crash Windows rapporté ; ils ne
constituent pas une correction démontrée de cet incident. `check build assemblePack assembleTestPack` réussit en **2 min 17 s**,
124 tâches, avec `-x :sanctuary:runGameTest` (serveur dédié exclu conformément
au refus de son EULA). Les deux packs sont vérifiés : même JAR testé, sources
correspondantes, libellés FR/EN, textures et archives beta.099 conservées.
Reçu : `build/beta100-artifact.json`. Aucun déploiement personnel ni publication
du canal packwiz.
## Import `.schem` et incident Windows signalé
Les palettes Sponge v2/v3 utilisent maintenant le convertisseur natif
`References.BLOCK_STATE` quand leur `DataVersion` est connue et antérieure.
Seule une copie de palette en mémoire est convertie ; le fichier importé et
les sauvegardes ne sont jamais réécrits par cette opération. Une version
future, un bloc de mod absent ou une propriété réellement incompatible restent
refusés. Sans version source, le parseur reste strict et ne devine pas une
conversion. Le message dimport indique le bloc/état fautif.
Le signalement Windows décrit un échec douverture, un monde absent de la liste
puis une fermeture du client lors du choix dun autre monde. Aucun journal ni
fichier précis nest disponible. Linspection ne trouve aucun accès de suppression
des mondes dans limporteur ; les plans actifs sont effacés en mémoire lors des
changements de connexion. Cela ne prouve pas la cause du signalement : sa
reproduction et le diagnostic Windows restent ouverts. Les fichiers utiles sont
`logs/latest.log` et, sil existe, le rapport daté de `crash-reports/` dans le
dossier Minecraft de linstance concernée. Ne pas supprimer ni convertir les
sauvegardes pour tenter de résoudre cet incident.
## Archives locales
- [Sanctuary-beta.100.mrpack](../build/Sanctuary-beta.100.mrpack), 9914561 octets.
SHA-256 : `7e24ba5e77c7fbb88cfadb55b3e373ad7e998381fd1c56e431aa79b4619a8075`.
- [Sanctuary-Test-beta.100.mrpack](../build/Sanctuary-Test-beta.100.mrpack), 9933483 octets.
SHA-256 : `a43bf61dc38c3c91f4cb09022040099ee1ca9274a6b00751178c80ee204dd944`.
+58
View File
@@ -0,0 +1,58 @@
# beta.101 — Atterrissage des familiers volants
Branche `codex/flying-landing-beta101`, Minecraft 26.3.
Le profil de vol du familier doit désactiver laccumulation et le traitement des
chutes de son entité commune. Une descente pilotée, autonome ou un contact avec
le sol ne doit causer ni dégâts, ni animation/son de coup, ni entrée en combat.
Les vraies attaques et les chutes des familiers terrestres gardent leurs règles.
Aucun changement de navigation, de sauvegarde, de texture ou de terrain.
## Cause et correction
Lentité Sanctuary commune hérite de `PathfinderMob`. Son contrôle de vol et
labsence de gravité ne remplacent pas les méthodes natives de chute. Ces
méthodes pouvaient appeler `FamiliarBattle.hurt`, produire son animation/son de
coup, interrompre une technique et enregistrer du combat.
`checkFallDamage` remet maintenant la distance à zéro pour les profils volants.
`causeFallDamage` refuse aussi un appel avec une distance déjà calculée, avant
les sons et les dégâts natifs. Les autres profils délèguent toujours au code
Minecraft. Les sources de dégâts ordinaires ne sont pas filtrées.
## Validation
Reproduction native sur beta.100 : le perroquet accepte le callback de chute,
et lassertion « Flying familiar rejects native fall callback » échoue.
Journal conservé : `build/landing101-before.log`.
`Landing101ClientChecks` réussit après correction en **1 min 5 s**, dans un
nouveau monde plat de développement avec le serveur intégré :
- Les douze espèces du registre ayant un profil volant : perroquet,
chauve-souris, abeille, allay, Wither, breeze, phantom, vex, blaze, ghast,
ghast joyeux et dragon.
- Callback natif de chute à sept blocs, descente à 0,25 bloc par tick jusquau
contact réel avec le sol, puis mouvement rapide vers le sol.
- Santé inchangée, distance de chute nulle et absence de mise à jour de
`lastHurt`, donc pas de passage dans la branche qui diffuse le faux coup.
- Une attaque de zombie reste acceptée et enlève de la santé pour chaque espèce.
- Un loup terrestre conserve les dégâts de sa chute.
Les descentes sont pilotées par le test via les collisions natives ; ce nest
pas un nouveau playtest de tous les trajets autonomes ou un essai multiclient.
Journal : `build/landing101-client.log`. Aucun monde personnel nest utilisé.
`check build assemblePack assembleTestPack` réussit en **2 min 22 s**,
124 tâches, avec le serveur dédié exclu (`-x :sanctuary:runGameTest`).
Les archives normal/Test embarquent le même JAR vérifié et ses sources exactes.
Seul `FamiliarEntity` et les numéros de ligne de deux classes internes changent
par rapport à beta.100. Les ressources, textures et archives beta.100 sont
conservées. Reçu : `build/beta101-artifact.json`. Aucun déploiement personnel
ni publication du canal packwiz.
## Archives
- [Sanctuary-beta.101.mrpack](../build/Sanctuary-beta.101.mrpack), 9914792 octets.
SHA-256 : `3008127d2bb09556c4997c00350e82ff26af5402b69bc7c09c822b24ff4bfe74`.
- [Sanctuary-Test-beta.101.mrpack](../build/Sanctuary-Test-beta.101.mrpack), 9933717 octets.
SHA-256 : `854a8b6a42ac7ca0236e0c19b9000808535df273911b9c6a72844097a49358a0`.
+79
View File
@@ -0,0 +1,79 @@
# beta.094 — Foyers allumés du Fourneau
La chaleur partagée allume toute la façade du Fourneau, y compris si la cuisson
se déroule dans une case intérieure. Les deux ouvertures reçoivent des braises
orange et jaunes. La pierre et les trois textures originales sont conservées.
Quand la chaleur est épuisée, la façade reprend son aspect éteint.
Les 27 sources de particules des fours composants sont remplacées par une seule
animation de façade : une flamme et une fumée dans chacun des deux foyers.
Leurs positions correspondent aux pixels des ouvertures, à 0,02 bloc devant la
face, dans les quatre orientations. Un four isolé garde son animation native.
La dissociation rétablit l'état lumineux individuel des fours.
## Assets et rendu
- Original inchangé : `assets/sanctuary/textures/block/fourneau/front.png`.
- Variante de feu : `assets/sanctuary/textures/block/fourneau/front_on.png`.
- `tools/fourneau-embers.json` délimite les bandes des ouvertures à illuminer.
- `python3 tools/generate-fourneau-models.py` régénère les modèles éteints/allumés.
La variante a été créée avec l'outil intégré **imagegen**, puis réduite en
48 × 48 par échantillonnage au plus proche pour la densité native du modèle.
La génération ayant aussi redessiné la pierre, le rendu prélève uniquement les
pixels de feu dans les ouvertures ; tout le reste utilise l'image originale.
Les fines faces de braises sont décalées de 0,002 pixel pour éviter le z-fighting.
Prompt retenu : « Edit the provided 48 by 48 pixel Minecraft furnace facade
texture. Create its LIT variant. Preserve the pixel-art grid, grey stone, mortar,
arches, outer edges and dimensions. Change only the black/dark interiors of the
two arched openings to Minecraft-style orange, red and yellow embers/fire pixels,
bright yellow near the bottom, orange above. Flames stay inside the holes.
No perspective, border, labels or new elements. »
Le choix du modèle utilise l'état `LIT` natif. Les snapshots d'assemblage, leurs
paquets réseau et le schéma de sauvegarde restent identiques. Aucun paquet de
particules n'est envoyé par le serveur. Aucun monde personnel n'est modifié.
Le pack de textures intégré reste à son édition beta.090 ; ces assets sont ceux
du mod beta.094.
## Vérifications
Le test client natif avec serveur intégré est passé sur un nouveau monde plat de
développement, graine 85 (`build/fourneau094-client.log`, 1 min 5 s) :
- 27 composants synchronisés à cheval sur quatre chunks, quatre orientations.
- Chauffe réelle depuis une case intérieure, façade entière allumée puis éteinte.
- 800 émissions interceptées à la sortie de l'animation native : coordonnées
devant la façade, sur un pixel de feu situé dans une ouverture sombre originale.
- Aucune nouvelle particule à froid ; particules natives d'un four isolé conservées.
- Rechargement des ressources, départ/retour dans les chunks, dissociation,
reconstruction et destruction ; conservation du contenu d'origine.
- Captures allumé/éteint inspectées dans `build/fourneau094-evidence/`.
Commande du test :
```sh
./gradlew :sanctuary:runClientGameTest -x :sanctuary:runGameTest \
-PsanctuaryClientTests=true -PsanctuaryFourneau085ClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryClientNoVsync=true
```
Les particules déjà émises finissent naturellement leur durée de vie après
l'extinction, comme celles des fours Minecraft. Test natif macOS ; pas de test
avec shaders tiers ni de session LAN à deux clients pour cette livraison.
Le serveur dédié de GameTest est exclu, conformément au refus antérieur de son
EULA. Aucun déploiement personnel ou changement de sauvegarde.
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` : réussi
en 2 min 31 s, 124 tâches (`build/fourneau094-check.log`). La régénération des
216 modèles est reproductible. `git diff --check` passe.
Les archives normal/Test ont été exportées et vérifiées : Minecraft 26.3,
version beta.094, même JAR Sanctuary intégré aux deux packs, sondes de test
absentes. Les assets existants, JEI et les archives beta.093 sont inchangés.
Le reçu `build/fourneau094-artifact.json` conserve les empreintes SHA-256 ;
`build/verify-fourneau094.py` reproduit ces contrôles.
- [Pack normal](../build/Sanctuary-beta.094.mrpack).
- [Pack de test plat](../build/Sanctuary-Test-beta.094.mrpack).
+74
View File
@@ -0,0 +1,74 @@
# beta.115 — Dépôts et chauffe du Fourneau
Ticket sur `codex/fourneau-transfers-beta115`, après le signalement de piles
qui se réalignent pendant les dépôts rapides avec Maj, dans Ingrédients et
Combustibles, et d'une chauffe difficile à comprendre.
## Diagnostic
La méthode `mayPlace` de la case serveur utilisait `index`, masqué par le
champ hérité `Slot.index` (position affichée). Elle validait donc successivement
une entrée, un combustible et une sortie, au lieu des 27 entrées ou des
27 combustibles. Le client acceptait le dépôt puis le serveur le refusait.
Le test reproduit le refus dun déplacement de la case 1 vers la case 23.
Le correctif utilise explicitement `physicalIndex` pour la validation.
## Comportement
Les transferts Maj dans les vues complètes du Fourneau sont anticipés sur le
client, avec les mêmes destinations et règles de combustible que le serveur.
Les cases se remplissent dans l'ordre, après fusion des piles compatibles.
Un dépôt manuel conserve la case choisie ; aucun tri périodique des ingrédients
n'est ajouté. Les recherches masquent une partie du stock : leurs transferts
entrants continuent d'attendre la réponse du serveur, qui connaît tout le stock.
La chauffe emprunte du combustible à une autre case uniquement pendant le tick
natif. La pile non brûlée retourne immédiatement à sa case d'origine, avec tous
ses composants. Les restes natifs restent associés à leur cuisson : un seau de
lave devient un seau, puis un seau d'eau avec une éponge mouillée.
Les 27 entrées ont chacune une barre et une infobulle : cuisson en cours,
manque de combustible avec progression conservée, résultat bloqué, absence de
recette. Le panneau indique le nombre de cuissons et la réserve allumée en
secondes de travail cumulées. Ce n'est pas une durée réelle restante : les
cuissons parallèles se partagent la réserve. « Fonctionnement » explique les
onglets, les gestes et le rendement normal du combustible en FR/EN.
Les recettes, résultats, expérience et règles de trémies restent natifs.
Aucun changement de format, de monde ou de génération ; les 27 fours physiques
conservent l'unique copie des objets et de leurs temps de chauffe.
## Vérifications
Le test natif `Furnace115ClientChecks` passe avec un serveur intégré, sur un
nouveau monde plat de graine 115 : vrai glisser avec Maj sur neuf piles nommées,
remplissage consécutif, déplacement manuel vers la case 23 et répartition native
sur six cases de colonnes différentes. Les 27 cases de chaque onglet valident
leur rôle physique. Le combustible nommé conserve sa position et ses composants.
La cuisson parallèle, ses états synchronisés, la progression conservée à court
de combustible, les 27 cuissons ravitaillées, le seau de lave et l'éponge,
l'extraction des résultats et la sauvegarde native passent. Captures FR/EN
relues dans `build/furnace115-evidence/`. Marqueur `FURNACE115_PASS` dans
`build/furnace115-client.log`.
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussit
en 2 min 29 s, 125 tâches. Le panneau d'aide reçoit ensuite une dernière
vérification client et une reconstruction ; la livraison commune beta.116
repasse les contrôles complets en 2 min 23 s et le scénario Fourneau en 42 s.
Le GameTest dédié reste exclu conformément au refus antérieur de son EULA.
Pas d'essai Windows ou de réseau distant avec latence simulée. Aucun monde
personnel ouvert. Cette étape reste locale et rejoint la livraison commune
avec les matériaux de sculpture : [beta.116](sculpture-materials-beta116.md).
Archives locales vérifiées (même JAR Sanctuary, sources concordantes, aucune
classe de test ni changement de production extérieur au ticket) :
- `Sanctuary-beta.115.mrpack` : 10184430 octets, SHA-256
`8fcac63b1bf5a01f680fb46666924705db3a2675e102037278e9d650d04cbf22`.
- `Sanctuary-Test-beta.115.mrpack` : 10203354 octets, SHA-256
`5b98fdb8842690158a20b422d6aa5496d74e572b3429498ffb25476d24e87f8a`.
- JAR Sanctuary : `2066d3191d7ee27904927fab269d8160136f048742c3223713e8ed4fe90dd8f5`.
Reçu : `build/furnace115-artifact.json`.
+152
View File
@@ -0,0 +1,152 @@
# GEM-01 — Gemmes, argile et découvertes — beta.126
Contrat du 17 septembre 2026, branche `codex/gems-beta126`, Minecraft 26.3.
Deux gemmes natives complètent l'émeraude : `sanctuary:ruby` et
`sanctuary:sapphire`. Chaque famille comprend un bloc de stockage, un minerai
ordinaire et un minerai des abîmes. Les huit PNG du créateur sont copiés à
l'identique ; leurs empreintes figurent dans `tools/gems-textures-beta126.json`.
Les noms des fichiers français `saphir` sont associés aux identifiants anglais
stables `sapphire`, sans modifier les pixels. Les deux items utilisent les fichiers de remplacement
`Desktop/ruby.png` et `Desktop/saphir.png` fournis en fin de livraison.
L'ordre créatif est émeraude, rubis, saphir : ingrédients, blocs de stockage,
minerais ordinaires et minerais des abîmes dans leurs onglets natifs respectifs.
Minage à la pioche de fer ou mieux, résistance et expérience comme l'émeraude,
Fortune, Toucher de soie, cuisson/four à fusion et conversion 9 gemmes ↔ 1 bloc.
Les blocs peuvent former un socle de balise et les gemmes servir de paiement.
Les recettes rejoignent la collection actuelle de l'émeraude, sans ajouter de
transactions de villageois. Libellés français et anglais, tags communs `c:`.
## Génération procédurale et contrat de compatibilité
Profil initial **gemmes 1 / beta.126** : quatre tentatives par chunk et par
gemme, filons natifs de taille 3, hauteur uniforme Y0256, probabilité de rejet
au contact de l'air de 0,25. Ces nombres sont un réglage initial à équilibrer
lors de la future refonte ; aucun quota de gemmes par chunk n'est garanti.
Les variantes ordinaires remplacent les roches du tag vanilla
`stone_ore_replaceables`, les variantes des abîmes celles de
`deepslate_ore_replaceables`.
Les fichiers `worldgen/feature/ore_<gemme>.json`,
`worldgen/placed_feature/ore_<gemme>.json` et les tags de biomes
`has_ore/<gemme>` séparent les cibles, la forme, la fréquence, la hauteur et
les régions. Un datapack peut les remplacer sans recompiler le mod. Le code
réutilise `OreFeature` et le hasard natif issu de la graine ; il limite ces
filons aux mondes utilisant `SanctuaryChunkGenerator`. Les biomes vanilla
partagés avec Sanctuary restent compatibles sans ajouter les gemmes aux
mondes vanilla, au Nether ou à l'End.
Cette évolution additive est autorisée pour les **nouveaux chunks uniquement**.
Aucun remplissage rétrospectif, aucune régénération, aucun remplacement de chunk
sauvegardé ni activation d'expansion. Les codecs du relief, les dimensions et
le format des sauvegardes restent inchangés. Les filons suivent les protections
existantes de la décoration native. Les anciens profils expérimentaux précédant
le générateur Sanctuary unifié ne sont pas étendus.
## Argile spéciale dans le Clay Workshop
Le bloc d'argile vanilla conserve les couleurs échantillonnées sur le modèle
original, animal découvert ou import GLB. Les argiles colorées et les autres
blocs continuent à transférer leur palette de texture. Une même règle est
appliquée à l'aperçu client et au résultat serveur. Le changement de matériau
invalide le résultat même si les deux palettes sont identiques. Le texte de
l'atelier explique l'exception en français et en anglais.
Une sculpture coûte toujours un bloc ; aucune recette, découverte, donnée de
sculpture déjà fabriquée ni règle de placement n'est modifiée.
## Commande de découverte commune
`/sanctuary discovery all` découvre tous les blocs, objets et espèces enregistrés.
Les variantes `blocks all`, `items all` et `mobs all` ciblent une catégorie ;
`block <identifiant>`, `item <identifiant>` et `mob <identifiant>` ciblent une
entrée avec la complétion native. Un sélecteur de joueurs en fin de commande
est facultatif ; sans cible, elle s'applique au joueur qui l'exécute.
Exemples :
```mcfunction
/sanctuary discovery all
/sanctuary discovery mobs all
/sanctuary discovery block minecraft:stone
/sanctuary discovery item minecraft:diamond
/sanctuary discovery mob minecraft:cow
/sanctuary discovery all @a
```
La permission suit « Autoriser les commandes » en solo et les droits opérateur
sur serveur. La console doit préciser la cible. Les identifiants invalides,
l'air et les types d'entités sans espèce découvrable sont refusés.
Les blocs passent par le registre de découvertes et son événement existant ;
les objets passent par `RecipeService.discover`. Les collections et recettes
sont ensuite recalculées normalement. Les espèces rejoignent `seen_mobs`,
la même donnée que l'observation naturelle, consommée par les familiers,
le Métabli et le Clay Workshop. Il n'existe aucun drapeau de déblocage parallèle.
Les catalogues déjà ouverts sont à rouvrir après la commande.
La commande conserve les quantités possédées et les compteurs de combat,
minage, pose et fabrication. Elle ne donne pas d'objets, n'achète pas les skills
et n'attribue pas d'advancements. Les recettes réservées aux opérateurs gardent
leurs règles. Les historiques personnels sont enrichis sans changer leur
schéma ; la commande refuse les données illisibles plutôt que les remplacer.
## Vérifications
Client natif Minecraft 26.3 / Java 25, `Gems126ClientChecks`, réussi en
1 min 08 s. Les mondes sont jetables, sans sauvegarde personnelle ouverte :
- Les huit items et six blocs sont présents, avec les huit textures exactes.
Quatre groupes créatifs respectent émeraude → rubis → saphir, sans doublons.
- Douze recettes natives, découverte du bloc de stockage, outil fer minimum,
Fortune III, Toucher de soie et balises passent les contrôles serveur.
- Graine **126**, profil gemmes 1, 81 chunks autour de l'origine : 7 rubis dans
la pierre et 8 dans la roche des abîmes ; 15 saphirs dans la pierre et 10 dans
la roche des abîmes. La génération est refusée dans le monde plat vanilla.
Les minerais persistent à la reconnexion, et un minerai retiré ne réapparaît pas.
- L'argile conserve exactement les couleurs des voxels d'une vache et d'un GLB
texturé. Bois et argile colorée transfèrent toujours leur palette. Le retour
à l'argile, l'égalité aperçu/résultat et le coût d'un bloc en survie sont vérifiés.
- La commande native respecte « Autoriser les commandes », accepte une cible
et la console, refuse les identifiants invalides et permet une espèce, un
bloc, un objet, toutes les espèces ou tout le contenu. Les recettes liées
sont recalculées. La répétition est idempotente et les découvertes survivent
à la reconnexion sans inventer de compteurs de combat ou de possession.
Logs et captures dans `build/gems126-client.log` et
`mods/sanctuary/build/run/clientGameTest/screenshots/`. Le GameTest dédié
reste exclu conformément au refus antérieur de son EULA. Le build complet et
les archives sont vérifiés séparément avant publication.
Build final `./gradlew check build assemblePack assembleTestPack
-x :sanctuary:runGameTest` réussi en **2 min 10 s**, 126 tâches. Les contrôles
natifs de gameplay précèdent uniquement le remplacement final des deux PNG
d'items ; les huit fichiers livrés sont vérifiés à l'octet près contre les
sources, tous en 16 × 16. Les JAR de sources correspondent aux sources courantes.
L'audit entre les binaires beta.125 et beta.126 ne trouve que les 112 entrées
de production prévues. Les archives normal/Test contiennent le même mod et
le template reproduit exactement ses ressources exportables.
| Artefact | SHA-256 |
| --- | --- |
| `Sanctuary-beta.126.mrpack` | `716221ce24549781ab91f5437ad2310497b198a4b61c43c40c67a27984365ae5` |
| `Sanctuary-Test-beta.126.mrpack` | `bbf22d7dd72e6746538b4ec28686f699225d4a59372304e5db42140a80e88bd1` |
| `sanctuary-beta.126.jar` | `85dbe1b41d98751029bb62e87d259522f8609758896c797b1e02fa7656ec0342` |
## Publication et synchronisation
[Release beta.126](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.126)
publiée depuis `99860af312a1737d938812f3515fcc4acb4291b2` ; artefacts
immuables retéléchargés et vérifiés. Canal packwiz : `31749b95d21ce5bced6b9e5759499da6f8a67e6e`.
Deux synchronisations isolées, puis deux synchronisations de l'instance existante
**Sanctuary Beta** ont réussi. Un seul JAR Sanctuary beta.126, correspondant
à l'empreinte ci-dessus. Les **923 fichiers personnels** suivis sont conservés,
sauvegardes restées fermées ; copie préalable dans
`sanctuary-backups/before-beta.126/`.
Packs normal/Test et template copiés et vérifiés dans
`sanctuary-beta/build/`. Reçus locaux : `build/gems126-artifact.json`,
`build/gems126-isolated.json`, `build/gems126-prism.json` et
`build/gems126-publication.log`.
+109
View File
@@ -0,0 +1,109 @@
# TOOL-01 — Clé dorée et états de blocs — beta.120
Contrat du 17 septembre 2026, branche `codex/golden-wrench-beta120`.
Le créateur valide les gestes du debug stick : clic gauche choisit la propriété,
clic droit parcourt ses valeurs, Maj inverse le parcours. L'orientation est
proposée en premier, puis les propriétés de forme et les autres états natifs.
La sélection est personnelle, par type de bloc, pour la session courante.
Une indication dans la barre d'action affiche la propriété et sa valeur.
La clé ne casse pas le bloc, y compris en créatif, et ne remplace pas son type.
Les propriétés natives sont disponibles en survie avec la clé, sans condition
opérateur, dans le respect des droits de construction, protections, portée et
verrous de conteneurs. Comme avec le debug stick, les formes choisies restent
en place immédiatement ; les mises à jour ultérieures du jeu peuvent recalculer
les états dynamiques (redstone, connexions, cultures…).
Les multiblocs assemblés gardent leurs commandes d'ouverture/dissociation et
leurs propriétés techniques ne sont pas éditées individuellement. Un assemblage
complet garde priorité au clic droit ; Maj permet de régler les composants
non assemblés. Hors d'un assemblage complet, même un four, un baril, un piston
ou un établi reçoit l'action générique.
## Rotation de texture et contrat de stockage
La terre n'a pas d'orientation dans son blockstate vanilla. Une propriété
supplémentaire de la clé, « Rotation des textures », parcourt 0°, 90°, 180° et
270° sur les faces des modèles de blocs. La géométrie, l'éclairage, les teintes,
les ressources actives, le bloc, son inventaire et son butin restent natifs.
Les rendus spéciaux d'entités de blocs ne sont pas transformés par ce réglage
des faces ; leur orientation demeure modifiable par leurs états natifs.
Nouvelle donnée facultative `sanctuary:wrench_textures_v1`, attachée au chunk :
table immuable position entière → quart de tour (1 à 3). Une absence signifie
0°. Elle n'est créée qu'après une action de clé ; revenir à 0° retire l'entrée,
puis l'attachement s'il est vide. Aucun état vanilla ni format existant n'est
modifié ; aucun parcours, conversion ou régénération des anciens mondes.
Les modifications de blockstate du même type conservent la rotation. Casser,
remplacer ou déplacer par piston retire la rotation à l'ancienne position ;
les objets récupérés restent ordinaires. Les nouveaux clients la reçoivent
après le chunk natif, par lots bornés de 1 024 entrées au maximum.
Déconnexion, changement de dimension et déchargement libèrent le cache visuel.
Revenir à une ancienne version du mod supprime seulement cet effet visuel ;
les blocs conservent leurs identifiants, données et états natifs.
## Vérifications et livraison
Le parcours `Wrench120ClientChecks` passe en **47 s** sur Minecraft 26.3,
client macOS et serveur intégré, dans un monde plat jetable de graine 120 :
- **2 522 propriétés natives** parcourues, avec valeurs inverses et type conservé.
- Vrais clics sur les escaliers : orientation, moitié supérieure et forme,
sans casser le bloc en créatif ; gestes inverses et sélection distincte.
Les doubles événements créatifs d'un même clic sont filtrés à la cadence
native de cinq ticks.
- Terre en survie : rotation réelle des UV émis, positions des sommets et nombre
de faces identiques ; retour exact après quatre quarts de tour ou un inverse.
- Rechargement des ressources conservant les UV tournés ; capture de terre relue.
- Coffre tourné avec ses 23 diamants intacts, sans ouverture accidentelle.
- Assemblage/dissociation par vrais clics des quatre familles : Métabli, Fût,
Fourneau et super piston. Un clic gauche sur un assemblage ne modifie pas
les numéros de composants ; barils et pistons isolés tournent normalement.
- Refus en aventure, spectateur ou hors portée ; retrait des données visuelles
après remplacement, maintien lors d'un changement d'état du même bloc.
- Sauvegarde/reconnexion : rotation de terre côté serveur et client, état
natif de l'escalier et inventaire du coffre conservés.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryWrench120ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Marqueurs `WRENCH120_PASS` et `WRENCH120_PROPERTIES 2522` dans
`build/wrench120-client.log`, captures dans `build/wrench120-evidence/`.
Aucun monde personnel ouvert ; pas de validation Windows ni depuis deux
ordinateurs. Les rendus de faces natifs et le rechargement du pack sont vérifiés ;
pas de validation spécifique d'un shader tiers.
Le GameTest dédié reste exclu conformément au refus antérieur de son EULA.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 12 s**, 125 tâches. Les sources JAR correspondent exactement
aux sources du dépôt. La comparaison avec beta.119 limite les différences de
production aux classes de clé/assemblage, aux deux nouveaux hooks, au rendu
et aux libellés prévus ; toutes les autres ressources sont identiques.
Les packs normal/Test contiennent le même JAR Sanctuary et aucune sauvegarde
ni fixture de test.
- Sanctuary-beta.120.mrpack : 10237973 octets, SHA-256
`86b5e480a12ba20b5c1e308c77dbb54bdf8a56806915fb32ca2d16f9a72cc9c9`.
- Sanctuary-Test-beta.120.mrpack : 10256895 octets, SHA-256
`2e3818483ba7dfa6e5652b97933e105a44e7cc4b269fc567116bf895d54bd8d9`.
- JAR Sanctuary : SHA-256
`8e35ef0f134a46b7760604175ad768264946d31fdd0694982e049e7a928fde2a`.
Reçu local : `build/wrench120-artifact.json`.
## Publication et installation
La [release beta.120](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.120)
est publiée depuis `248dff47b7a0ebd11026230581fe69641dbde76d`. Le tag exact et les
artefacts sont immuables ; leurs téléchargements publics sont vérifiés.
Le canal packwiz avance à `e8060764a444a58b0a17318a3821dfeb1a0c33c6`.
Deux synchronisations isolées puis deux dans **Sanctuary Beta** réussissent.
Un seul JAR Sanctuary beta.120 est actif ; le second passage est identique.
Les **923 fichiers personnels et réglages suivis** restent inchangés, sans
ouvrir de monde personnel. Copie préalable dans
`sanctuary-backups/before-beta.120/` de l'instance existante. Reçus locaux :
`build/wrench120-isolated.json` et `build/wrench120-prism.json`.
+143
View File
@@ -0,0 +1,143 @@
# beta.105 — Météo saisonnière, captures et chapeaux vivants
Branche `codex/living-hats-seasons-beta105`, Minecraft 26.3.
## Contrat avant modification
- Molette dans le fût : navigation entre pages, bornée, sans perdre les objets
du curseur ni déplacer la souris au centre.
- Panoramas : couleurs et brume du shader Sanctuary actif, six faces cohérentes,
profondeur conservée et caméra restaurée même après erreur.
- Real Time : probabilités liées aux saisons astronomiques du calendrier réel.
Été surtout ensoleillé sans profil ciel gris ; automne plus humide ; hiver
avec neige. Des journées claires restent possibles dans les quatre saisons.
Le créateur demande explicitement flocons et accumulation naturelle au sol.
Le tirage du jour déjà sauvegardé reste stable ; nouveaux tirages saisonniers
les jours suivants, commandes opérateur disponibles. Aucune Weather TNT.
- Mobs équipés : coups et boules de neige activent lobjet ; briquet, feu
dartifice propulsant le porteur, spawner natif configuré, paratonnerre attirant
les éclairs. Les consommables et lusure restent réels.
- Cultures sur tête : croissance et récolte des produits/graines, sans duplication
de la graine initiale au retrait. Les aliments attirent les espèces qui les
mangent ; une carotte sur bâton guide les cochons vers le porteur.
- Livre et plume : journal intime écrit en réaction aux caresses et blessures,
voix différente selon les quatre personnalités ; souvenirs variés, pas de log
technique. Pages existantes conservées et limites natives respectées.
## Contrat de données
Aucune sauvegarde personnelle nest modifiée pendant les essais. Les objets et
leur état continuent dutiliser les données additives existantes de chapeau.
Les journaux restent de véritables livres Minecraft récupérables. L’éventuel
nouveau profil neige étend l’énumération météo : anciens états lisibles, aucune
conversion automatique du jour courant. Un retour à une ancienne version après
une journée neige nécessite dabord de sélectionner un profil météo ancien.
La neige peut modifier les blocs exposés pendant le jeu, selon la demande du
créateur ; pas de régénération, dexpansion ni de changement de terrain au chargement.
## Utilisation
- Dans le fût, molette vers le bas : page suivante ; vers le haut : précédente.
Le défilement concerne le coffre et sa colonne de contrôles. Un objet tenu au
curseur bloque le changement de page, comme les boutons existants.
- `/sanctuary weather forecast` annonce les sept prochains jours.
`/sanctuary weather snow` et `snow_showers` sélectionnent respectivement neige
continue et averses ; leurs équivalents `/weather` sont également disponibles.
Le mode Vanilla conserve le cycle Minecraft.
- Sur un mob équipé, clic droit à main vide : caresse du livre, activation du
briquet/de la fusée, récolte dune culture mûre. Coup ou boule de neige :
activation. Maj + clic droit à main vide : récupération de lobjet.
- Blé, carottes, pommes de terre, betteraves : croissance des cultures portées,
récolte native et replantation. Les produits mûrs sont aussi rendus si lon
retire la graine. Les plantes décoratives et tiges de melon/citrouille ne
deviennent pas des cultures de fruit mobiles.
- Le spawner conserve lespèce configurée et les contraintes Minecraft :
proximité dun joueur, espace, lumière et limite dentités. Une activation
lance une tentative immédiate ; elle ne garantit pas une apparition si
ces conditions ne sont pas réunies.
- Le paratonnerre attire les éclairs naturels sous un ciel dégagé à portée
native, puis émet son impulsion redstone. Le briquet respecte la règle de modification du terrain par les mobs.
La fusée consomme lobjet, utilise ses effets natifs et propulse le porteur.
- Les aliments portés attirent les animaux qui les consomment dans un rayon
de 10 blocs, avec abandon à 12 blocs ou sans visibilité. La carotte sur bâton
guide ainsi les cochons. Priorités de fuite et de reproduction conservées.
- Le journal reprend la personnalité du familier ; celle dun animal ordinaire
est déterminée de façon stable par son identité et son espèce. FR/EN selon
la langue du premier lecteur-interacteur. Les pages précédentes sont
conservées (limite native 100 pages), mais ne sont pas diffusées dans le
paquet dapparence du chapeau.
## Calendrier et périmètre
Longitude solaire apparente calculée localement à partir des formules publiques
[NOAA Solar Calculator](https://gml.noaa.gov/grad/solcalc/), avec contrôle des
quatre passages 2026 contre les horaires de l[US Naval Observatory](https://aa.usno.navy.mil/data/Earth_Seasons).
Le tirage est journalier : le jour civil dun équinoxe/solstice adopte la nouvelle
saison, selon le fuseau Real Time. Lhémisphère sud inverse les saisons via la
latitude solaire configurée. Il ne sagit pas de la météo réelle dune ville.
Les pondérations de `weather.json` restent la base, modulée par la saison.
Sur 10 000 graines par saison : journées claires 64,41 % / 94,79 % / 40,25 % /
49,05 % (printemps/été/automne/hiver) avec la configuration par défaut.
La neige passe par les précipitations natives des chunks chargés, respecte
la limite native daccumulation, la lumière et les supports Minecraft. Les biomes
sans précipitations restent secs. Les chaudrons reçoivent aussi de la neige.
La neige déposée reste un bloc natif : pas de balayage saisonnier supprimant
les blocs des constructions. Sa fonte suit les règles Minecraft.
Les captures intègrent le post-traitement et la brume Sanctuary ; aucune
compatibilité avec un moteur externe Iris/Canvas/Oculus nest déclarée.
## Vérifications
- `seasonal105Smoke` : 40 000 tirages, stabilité par graine/jour,
équinoxes/solstices 2026 à ± une heure, inversion sud, proportions saisonnières,
persistance dune journée de neige et passage au lendemain.
- `Living105ClientChecks` : client natif Minecraft 26.3 avec serveur intégré,
nouveau monde plat jetable. Journal avec page préalable, caresse/blessure,
récupération, récolte/replantation/retrait, vache attirée par le blé, cochon
guidé, vraie méthode dimpact de boule de neige, feu/usure, vol dune vache,
apparition du spawner configuré, éclair/impulsion/extinction du paratonnerre,
neige déposée par `tickPrecipitation`, requête de flocons côté client,
molette suivant/précédent/bornes et deux panoramas complets de six faces.
- Pour chaque face à saturation 0, contrôle des pixels désaturés ; comparaison
à saturation 200, présence de `sanctuary:vanilla_light` et profondeur du sol
conservée. Les vues sont soumises séparément au GPU pour éviter de réutiliser
les tampons natifs avant leur soumission. Aucun échec de post-traitement dans
lessai final (53 secondes).
Commande de reproduction :
```sh
JAVA_HOME=/chemin/vers/jdk25 ./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryLiving105ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Le délai serveur de trois ticks entre changements de page est supprimé : les
paquets sont toujours validés contre lidentifiant du menu ouvert et son curseur.
Les anciennes pages ne peuvent donc pas accepter de clics après remplacement.
Le client et le serveur doivent utiliser ensemble beta.105 pour les nouveaux
profils réseau de neige. Les anciens états enregistrés restent lisibles.
## Livraison locale
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussi
sur la copie isolée `build/release-beta105` : 2 min 43 s, 125 tâches.
Le test dédié est exclu conformément au refus antérieur de son EULA ; le test
natif de cette livraison utilise un serveur intégré dans un monde neuf jetable.
La copie part des sources et ressources vérifiées beta.104, auxquelles sont
appliqués les 25 fichiers Java concernés, les libellés météo FR/EN et les mixins.
Elle évite dembarquer les modifications du chantier Statuaire beta.106 arrivé
en parallèle dans le dossier partagé. Le travail beta.106 est conservé.
`build/beta105-source-manifest.json` décrit les sources de cette compilation.
Les sources JAR correspondent à cette copie ; les ressources du pack beta.104
restent identiques sauf les deux fichiers de langue. JAR sans classes de test,
archives et métadonnées 26.3 vérifiées ; archives beta.104 inchangées. Rapport :
`build/beta105-artifact.json`. Aucun déploiement ni publication du canal.
- [Sanctuary-beta.105.mrpack](../build/Sanctuary-beta.105.mrpack), 10083681 octets.
SHA-256 : `396c31ad80b4826e5cc0530001e90e498dfb3d36f951cb46d01b32870bf3212a`.
- [Sanctuary-Test-beta.105.mrpack](../build/Sanctuary-Test-beta.105.mrpack), 10102603 octets.
SHA-256 : `6398eb413ea45cf383b71d5be07cb1eef6995158fe0909b06deabb36c1623ce4`.
+104
View File
@@ -0,0 +1,104 @@
# beta.096 — Métabli et commandes de construction
Le parcours créatif est complété depuis [beta.098](creative-construction-beta098.md)
par un bouton de construction immédiate. Le présent document décrit beta.096.
## Contrat de monde avant implémentation
Neuf établis à plat, en carré 3 × 3, sont assemblés volontairement à la clé
avec la confirmation habituelle. Aucun établi existant n'est converti au
chargement. Les neuf composants deviennent `sanctuary:metatable`, propriété
`part` de 0 à 8 (x + 3z). Leur origine est déduite de cette propriété.
Ces états sont stockés normalement dans les chunks ; aucun SavedData ancien
n'est modifié, aucun inventaire ou contenu n'est copié. Pas de BlockEntity.
Le contrôle des neuf cases est borné et n'impose pas de chargement de chunk.
Une dissociation restaure neuf établis. Casser un composant dépose son établi
selon le butin natif et restaure les autres sans drops supplémentaires.
Les composants déchargés sont contrôlés à leur reprise. Une machine partielle
est inaccessible jusqu'à validation/restauration. Les pistons ne déplacent pas
un Métabli assemblé. Les permissions serveur s'appliquent à chaque composant.
Les deux PNG fournis sont conservés octet pour octet : dessus 48 × 48,
côtés 48 × 16, prélevés par tiers sans étirement. Le dessous utilise les
planches de chêne du pack actif.
## Parcours retenu
Clic droit au Métabli → catalogue → choix unique du projet → retour dans
le monde. K ouvre les commandes du projet : ancrer au point visé, décaler,
tourner, afficher une couche, masquer/afficher et consulter les besoins.
Changer de projet, créer une autre statue, importer ou abandonner le plan
se fait au Métabli. Esc ferme seulement l'interface et ne retire pas le plan.
Un import ou une génération en cours n'est accepté que si le joueur est encore
à l'atelier qui a initié le choix. Le choix ne pose aucun bloc.
Construction manuelle avec l'inventaire natif. Aucun collage ni chantier
automatique dans ce parcours, même en créatif. Les fonctions serveur de collage
créatif historiques restent contrôlées par leurs règles existantes.
Le projet et son aperçu restent locaux au joueur, comme dans beta.072 ; ils
sont effacés au changement de monde/dimension ou à la déconnexion. L'export
reste disponible pour les conserver. Le chantier partagé persistant, les
catalogues en datapack serveur et les services commerciaux sont des lots futurs.
Aucune économie n'est ajoutée.
## Catalogue initial et extension
- Statues : les modèles natifs déjà pris en charge, avec hauteur et palette.
- Machines : Fourneau, Fût, super piston normal et gluant. Le plan guide les
blocs sources ; la clé réalise ensuite leur assemblage fonctionnel.
- Bâtiments : abri de 5 × 5 et passerelle de 3 × 7, deux plans simples de départ.
- Mes plans : bibliothèque locale et imports existants `.litematic`, `.schem`
et `.nbt`. Les anciens `.schematic` demandent une conversion externe.
`ConstructionCatalog.ENTRIES` est le point dextension des plans intégrés :
identifiant, catégorie et fabrique de `BuildPlan`, avec nom et aide FR/EN.
Une machine fonctionnelle conserve son implémentation serveur propre : un plan
ne lui confère ni inventaire prérempli ni commandes ni service commercial.
Le catalogue configurable par datapack reste à implémenter.
## Utilisation
Poser neuf établis en carré à plat. Deux clics à la clé dorée confirment le
Métabli ; clic droit pour louvrir. Choisir un plan ferme le catalogue.
K ouvre les commandes de construction, avec aperçu, décalage dun bloc,
rotation par quart de tour, couches et matériaux restants. Poser les blocs
normalement ; revenir au Métabli pour changer de choix ou abandonner.
Maj + clic droit à la clé dissocie latelier.
## Vérifications
Deux suites natives sur Minecraft 26.3, en nouveaux mondes de développement :
- `Metatable096ClientChecks`, graine 96 : assemblage en deux clics sur quatre
chunks, ouverture par clic droit natif, quatre rubriques, sélection unique,
K réservé aux réglages, refus de changement/import à distance, rotation et
translation, pose native payée, annulation dune génération en quittant
latelier, statue puis export/import, interfaces FR/EN, casse pendant le menu,
un seul drop et huit établis restaurés, réassemblage, déchargement réel des
chunks puis retour, dissociation des neuf cases. Réussi en 1 min 11 s.
- Régression `Plans072ClientChecks` adaptée au Métabli : capture des **88 modèles**
sans refus, statue de 32 blocs, rotations, matériaux, export, ancien protocole
de collage créatif et refus en survie, pose native consommant son bloc,
interfaces FR/EN. Réussi en 1 min 7 s.
Captures inspectées dans `build/metabli096-evidence/`. Le premier passage du
nouveau test attendait le déchargement via `ClientLevel.hasChunkAt`, qui ne
reflète pas le cache client ; le test utilise désormais `getChunkNow`, comme
les autres essais natifs. Le parcours fonctionnel précédant cette attente
avait déjà réussi. Ce premier journal est conservé séparément.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussi : 124 tâches, 2 min 20 s. Voir `build/metabli096-check.log` et le reçu
`build/metabli096-artifact.json`. Textures dorigine comparées octet pour octet,
modèles découpés par tiers, JAR sources et archives vérifiés ; archives beta.095
conservées. Les tests dédiés sont exclus conformément au refus antérieur dEULA.
Aucun serveur dédié démarré, aucune EULA acceptée, aucun déploiement personnel.
## Livraison locale
Packs normal et Test `build/Sanctuary-beta.096.mrpack` et
`build/Sanctuary-Test-beta.096.mrpack`, Minecraft 26.3 finale.
Le canal packwiz et linstance Prism ne sont pas modifiés. Les dépendances et
le pack de textures intégré beta.090 restent identiques ; les textures du
Métabli sont les ressources propres du mod.
+185
View File
@@ -0,0 +1,185 @@
# Métabli — atelier de construction et catalogue extensible
Statut : **conception de référence**, le 16 septembre 2026.
Le catalogue, les commandes K et lexemple généré évoluent dans
[beta.099](catalogue-progression-beta099.md).
Le premier lot est livré dans [beta.096](metabli-beta096.md), branche
`codex/metabli-beta096` : multibloc, catalogue et commandes de construction.
Les chantiers partagés persistants, datapacks de catalogue et services décrits
ci-dessous restent des propositions pour les lots suivants. Le contrat beta.096
précise le fonctionnement effectivement implémenté.
## Direction demandée
Un multibloc posé dans le monde ouvre le menu de construction. Aucun bouton
supplémentaire dans le menu pause. Le système réunit statues, machines et
bâtiments, et doit pouvoir accueillir de nouveaux contenus et usages.
« Métabli » reprend ici le nom du nouveau message ; le catalogue historique
employait « Métablit ». Aucun identifiant publié n'est renommé.
## Ce que le jeu possède déjà
- `PlansScreen` propose Statue, Bibliothèque et Construction via K.
- `StatueVoxelizer` et la capture cliente produisent des statues à partir des
modèles et textures du jeu ; `BuildPlan` porte les blocs et leurs états.
- `PlanClient` sait ancrer un aperçu, le tourner, isoler une couche et compter
les blocs corrects, restants et en conflit. Cet état est actuellement local.
- `SchematicIO` lit les formats pris en charge dans la bibliothèque ; l'import
ne reprend pas les inventaires, scripts ou entités des fichiers tiers.
- `PlanService` autorise le collage uniquement en créatif. En survie, le joueur
pose aujourd'hui ses blocs lui-même : aucun moteur de chantier automatique.
- La clé dorée et les machines existantes donnent un précédent pour l'assemblage
volontaire, la dissociation et l'autorité serveur.
Références : [plans existants](statues-plans-beta072.md),
[multiblocs existants](multiblocs-beta084.md),
[catalogue historique](multiblocs-conception.md).
## Le Métabli dans le monde
Proposition de forme : **neuf établis à plat, en 3 × 3 sur un bloc de hauteur**,
assemblés volontairement avec la clé dorée. Forme et apparence à confirmer
avant le code ; ne pas imposer les 27 composants du Fourneau à cette machine.
Un clic droit sur un composant assemblé ouvre le même atelier.
Le Métabli sert à choisir, préparer et gérer un projet. La construction est
ancrée à un autre emplacement choisi dans le monde ; elle n'a pas à occuper
la place de l'atelier. L'aperçu indique clairement sa base et son orientation.
Proposition initiale : un chantier actif par Métabli, plusieurs participants.
K reste disponible pour l'aperçu et les réglages du plan en cours. Le premier
lot ne supprime pas les outils locaux déjà livrés ; le nouveau catalogue et
la gestion du chantier partagé sont accessibles depuis le Métabli.
## Un menu commun
Interface native et sobre : catégories en haut, liste et recherche à gauche,
aperçu et fiche du projet à droite, actions en bas. Les catégories n'ouvrent
pas quatre nouveaux menus indépendants.
| Catégorie | Contenu | Réglages utiles |
| --- | --- | --- |
| Statues | Générateur actuel de mobs ; autres modèles ultérieurement | Modèle, taille, palette |
| Machines | Fourneau, Fût, super pistons ; plans de circuits redstone validés | Orientation, variante, entrées et sorties |
| Bâtiments | Maisons, ateliers, ponts et autres plans ajoutés au catalogue | Dimensions, variantes de matériaux compatibles |
| Mes plans | Bibliothèque locale et imports actuels | Nom, dimensions, matériaux, rotation |
Une fiche affiche la fonction réelle, l'encombrement, les matériaux requis
et manquants, ainsi que la condition de déblocage éventuelle. Les exemples de
bâtiments et de circuits ci-dessus sont des contenus à créer, pas un catalogue
déjà présent. Une fonction absente du jeu n'est pas présentée comme disponible.
Parcours proposé : choisir → configurer → positionner l'aperçu → confirmer
l'emplacement → construire → utiliser. Une fiche de chantier conserve
l'avancement et les besoins en matériaux. Le mode retenu par le créateur est la **construction manuelle guidée** :
les joueurs apportent leurs matériaux et posent eux-mêmes les blocs.
## Mode retenu : construction manuelle guidée
Décision confirmée par le créateur le 16 septembre 2026. Le Métabli ne place
pas les blocs et ne prélève pas les objets. La pose, les outils, les matériaux
et les règles de voisinage restent ceux du jeu. Aucun moteur automatique,
réserve de chantier, délai de fabrication ou consommation différée à créer.
Réutiliser l'aperçu existant : les blocs terminés s'effacent de la projection,
les conflits sont signalés et les couches permettent de suivre un grand plan.
Le chantier commun pourra partager l'ancrage et la progression ; chaque joueur
contribue avec son propre inventaire. Le contrôle de droits sur la construction
elle-même reste celui du monde, indépendamment des droits d'édition du plan.
La fiche distingue **blocs à placer** et **objets nécessaires**. Le compteur
actuel travaille sur les états de blocs ; annoncer une liste exacte d'objets
exige d'adapter les portes et lits (un objet, deux cases), les dalles doubles
(deux objets) et les autres cas particuliers. Aucun de ces calculs ne peut
modifier ou doubler la consommation native du joueur.
## Une construction a un plan et, parfois, une fonction
Le **plan** décrit la forme : blocs, états, variantes et points de raccordement.
La **fonction** décrit un comportement éventuellement ajouté par Sanctuary.
- Une statue ou une maison ordinaire a seulement besoin de son plan.
- Un circuit redstone fonctionne grâce aux blocs réellement construits.
- Un Fourneau doit en plus être validé et assemblé par le système existant.
- Une boutique aura besoin d'un composant commercial : propriétaire, stock,
offres et échanges. Construire son bâtiment ne crée pas ce service à lui seul.
Pour les machines reconnues, le plan complet et les permissions sont vérifiés
avant l'assemblage final ; un simple import de blocs ne peut pas s'attribuer une
fonction spéciale. Les ports indiqués dans le plan tournent avec la structure
(entrée d'objets, sortie, commande redstone, face d'interaction).
La demande de boutique donne un point d'extension, **pas une livraison de
l'économie maintenant**. On pourra préparer un local de boutique et lui
rattacher le service lorsque le commerce sera défini et implémenté. Son futur
signal redstone pourra par exemple indiquer un stock disponible ou une vente,
sans confondre le signal avec l'opération d'échange elle-même.
## Étendre le catalogue sans refaire l'interface
Proposition : des définitions de catalogue en datapack serveur, avec des
identifiants stables et une version de définition. Une entrée comporte :
- nom et description FR/EN, catégorie et tags ;
- source du plan ou générateur connu, paramètres et variantes autorisées ;
- condition de déblocage vérifiable côté serveur ;
- matériaux et opérations de placement, calculés depuis la variante retenue ;
- ports de raccordement et type de fonction facultatif.
Ajouter un bâtiment ou un circuit utilisant les mécanismes existants revient
à ajouter un plan et sa fiche. Une nouvelle mécanique, comme le commerce ou
une machine inédite, demande un module de code serveur enregistré, puis une
fiche qui l'utilise. Un datapack ne devient pas un interpréteur de commandes
arbitraires et un fichier importé ne fournit pas son propre code exécutable.
Les générateurs de statues et les fichiers locaux deviennent deux fournisseurs
de plans parmi d'autres. Les catégories servent à naviguer ; elles ne dictent
pas le moteur de placement. Découvertes, advancements ou aptitudes pourront
débloquer des entrées, sans supposer que ces règles existent déjà pour les plans.
## Persistance et reprise à définir avant le code
Le registre actuel des multiblocs décrit un cube de 27 cases avec un booléen
Fourneau/Fût. Il n'est pas extensible à un Métabli par ajout d'une troisième
valeur. Prévoir un registre dédié pour cette nouvelle machine et ses chantiers,
sans modifier le schéma existant ; un contrôle commun empêchera de partager
un composant avec les anciennes machines.
Le futur contrat versionné devra fixer l'identité du Métabli et du projet,
la dimension, l'ancrage, l'orientation, les droits d'édition et la reprise
du suivi. Les matériaux restent dans les inventaires Minecraft ; aucun stock
dupliqué n'appartient au plan. Conserver une copie bornée du plan
validé et de sa révision évite qu'une mise à jour du catalogue transforme un
chantier en cours ou un bâtiment déjà posé. Les limites actuelles de `BuildPlan`
restent le point de départ, pas une promesse de gros plans sans limite.
Proposition de règles : aucune transformation automatique des établis existants ;
la projection n'écrase jamais le terrain ; suivi suspendu hors chunks chargés,
sans forcer leur chargement. Une casse du Métabli ou une annulation du projet
ne supprime aucun bloc déjà posé et ne restitue pas de matériaux déjà utilisés.
Les projets complets ne déclenchent pas une deuxième production.
L'activation d'une machine reconnue reste une action explicite, après contrôle
serveur, selon les règles de la clé. Le suivi partagé doit persister le plan,
pas une copie ancienne du monde à rejouer après reconnexion.
Ces règles et ce registre sont un cadrage, **pas une migration approuvée ni un
format livré**. La forme du Métabli, les droits de gestion, le rayon d'ancrage
et les déblocages restent à fixer avant le lot concerné.
## Lots proposés
1. Métabli ouvrable, interface commune, statues et bibliothèque actuelles,
sélection d'un plan et projection à l'emplacement voulu. Aucun bouton pause.
2. Chantier manuel partagé et persistant ; liste correcte des matériaux,
synchronisation du suivi et vérifications de reprise/annulation.
3. Catalogue initial de machines et bâtiments validés, assemblage final des
machines existantes et protocole d'ajout de contenu en datapack.
4. Modules de fonction supplémentaires lorsque leur gameplay est défini,
notamment le commerce ; hors premier lot.
Chaque lot devra avoir un résultat jouable, un contrat de sauvegarde s'il en
crée un, les libellés FR/EN et les vérifications client/serveur adaptées.
La validation du chantier manuel devra notamment couvrir deux joueurs, une
pose qui consomme réellement l'objet natif, un obstacle ajouté pendant la
construction, une porte/un lit, un circuit, le déchargement, la reconnexion et
la casse du Métabli sans disparition des blocs construits.
+292 -1
View File
@@ -8,11 +8,273 @@ https://git.botsu.net/koka/sanctuary-beta/raw/branch/packwiz/pack.toml
```
Les sources sont sur la branche de travail du ticket. La branche `packwiz`
contient uniquement les manifestes de distribution produits par le build ; les
contient les manifestes de distribution produits par le build et licône
`icon.png` déclarée dans leur index ; les
JAR Sanctuary sont des pièces jointes de releases Gitea. Aucun JAR de mod ou
dépendance téléchargée n'entre dans l'historique Git. Le canal Beta est distinct
de l'ancien pack 26.2.
## beta.129 — reflets CTM et portées 16256 blocs
Reflets connectés entre tous les matériaux coplanaires, portée indépendante,
ombres à 128/256 blocs, intensité rétablie à midi et correction du réalignement
cyclique de la grille lumineuse. [Contrat et vérifications](shader-beta129.md).
[Release beta.129](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.129)
publiée ; canal `2eaa0130a4cd475c598beefdab40f48fc8681ab7`. Deux synchronisations isolées
puis deux dans la même instance **Sanctuary Beta** réussies. Les 923 fichiers
personnels suivis, réglages de shaders compris, sont conservés. Aucun monde
personnel ouvert ; copie préalable dans `sanctuary-backups/before-beta.129/`.
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
## beta.128 — stabilité des ombres, lune et feuillage
Ombres des blocs nettes et stabilisées, atténuation à midi, lune légère et
feuillage nuancé selon son épaisseur. [Contrat et vérifications](shader-beta128.md).
[Release beta.128](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.128)
publiée ; canal `eba01726ee132a6e548d129fa7ee04f24c880917`. Deux synchronisations isolées
puis deux dans la même instance **Sanctuary Beta** réussies. Les 923 fichiers
personnels suivis, réglages de shaders compris, sont conservés. Aucun monde
personnel ouvert ; copie préalable dans `sanctuary-backups/before-beta.128/`.
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
## beta.127 — shader et ombres pixélisées
Ombres solaires optionnelles intégrées à Sanctuary · Vanilla Light, avec finesse
et portée réglables. Socle beta.126 conservé. [Contrat et vérifications](shader-beta127.md).
[Release beta.127](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.127)
publiée ; canal `7fd24a43e01ee497a23311e3c78e1dd3dddd877a`. Deux synchronisations
isolées puis deux dans la même instance **Sanctuary Beta** réussies.
Les 923 fichiers personnels suivis, réglages de shaders compris, sont conservés.
Aucun monde personnel ouvert ; copie préalable dans `sanctuary-backups/before-beta.127/`.
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
## beta.126 — gemmes, argile et découvertes
Rubis et saphir, nouvelles textures d'items, argile conservant les couleurs
originales et commandes de découverte communes aux recettes et catalogues.
[Contrat et vérifications](gems-beta126.md).
[Release beta.126](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.126)
publiée ; canal `31749b95d21ce5bced6b9e5759499da6f8a67e6e`. Deux synchronisations
isolées puis deux dans la même instance **Sanctuary Beta** réussies.
Les 923 fichiers personnels et réglages suivis sont inchangés ; sauvegardes
restées fermées. Copie préalable dans `sanctuary-backups/before-beta.126/`.
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
## beta.125 — placement, éclats et animaux sculptés
Coins et trois hauteurs, particules de voxels et espèces découvertes au Clay
Workshop. [Contrat et vérifications](sculpture-placement-beta125.md).
[Release beta.125](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.125)
publiée ; canal `47eb833911a96eef0b13805dafe65b2ab7b95c87`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable dans
`sanctuary-backups/before-beta.125/`. Packs normal/Test et template copiés
et vérifiés dans le dossier habituel `sanctuary-beta/build/`.
## beta.124 — une boîte par sculpture
Une boîte ajustée commune à la sélection et aux collisions ; détails physiques
simplifiés, orientation/ancrage et éclairage conservés.
[Contrat et vérifications](sculpture-bounds-beta124.md).
[Release beta.124](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.124)
publiée ; canal `67ebcc7d07f8b75e8988f6099e3d6e5704f123fb`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.124/`. Packs normal/Test et template également
copiés et vérifiés dans le dossier habituel `sanctuary-beta/build/`.
## beta.123 — clé dorée et clé de Steve
La clé dorée se limite aux orientations cohérentes ; la clé de Steve reprend
les états et textures libres, utilisables aussi en survie dès sa récupération.
[Contrat et vérifications](wrench-safety-beta123.md).
[Release beta.123](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.123)
publiée ; canal `f988d01b901f2d3eda0b4ab47a48540dc627d1b5`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.123/`.
## beta.122 — formes et lumière des sculptures
Sélection et collisions sur les voxels réels ; suppression de l'occlusion cubique.
[Contrat et vérifications](sculpture-shape-light-beta122.md).
[Release beta.122](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.122)
publiée ; canal `3d340bea0093b709c6018624795a1b3ca557b72d`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.122/`.
## beta.121 — texture de clé et pack personnel
PNG du créateur et copie complète modifiable depuis l'écran des packs.
[Contrat et vérifications](resourcepack-template-beta121.md).
[Release beta.121](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.121)
publiée ; canal `e3e6572da9288fbbcfa36bf6b7dd3aa0d1c54f5e`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.121/`. Le template personnel est créé par le
joueur, jamais géré ou réécrit par packwiz.
## beta.120 — clé dorée et états de blocs
Gestes du debug stick, rotation des textures et assemblages prioritaires.
[Contrat et vérifications](golden-wrench-beta120.md).
[Release beta.120](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.120)
publiée ; canal `e8060764a444a58b0a17318a3821dfeb1a0c33c6`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.120/`.
## beta.119 — pose centrée des sculptures
Centre accessible au point visé, bords conservés et grille entière de 1/16.
[Contrat et vérifications](sculpture-center-beta119.md).
[Release beta.119](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.119)
publiée ; canal `5e823af7807f524f6394acad402fe62328c7e4ab`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.119/`.
## beta.118 — murets en briques colorées
Seize murets natifs, recettes, tailleur, eau et série créative par couleurs.
[Contrat et vérifications](colored-brick-walls-beta118.md).
[Release beta.118](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.118)
publiée ; canal `e6bab433bea9528f67d39af0d6c7bcb6443e4817`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.118/`.
## beta.117 — grain des textures et sculptures en chapeau
Échantillonnage des pixels de plusieurs textures, grain stable même sur un
modèle uni et conservation des données du chapeau.
[Contrat et vérifications](sculpture-texture-grain-beta117.md).
[Release beta.117](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.117)
publiée ; canal `a5b75a70f86e42afb8e06ba4daa9880d61c6373e`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.117/`.
## beta.116 — palettes de sculpture et Fourneau
Le Clay Workshop utilise les nuances des textures de blocs ; le Fourneau
corrige les décalages de cases et explique la chauffe.
[Contrat et vérifications](sculpture-materials-beta116.md).
[Release beta.116](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.116)
publiée ; canal `c741870160f8a21f0a9793f20f0e624ebdbacdb6`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.116/`.
## beta.110 — version commune
Clay Workshop, Statuaire et import GLB intégrés aux œufs et à la neige saisonnière.
[Contrat, vérifications et suivi de mise à jour](clay-workshop-integration-beta110.md).
[Release beta.110](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.110)
publiée ; canal `828353a6e2bd747f4f473ae9fb9e965d38032419`. Deux synchronisations
isolées puis deux dans la même instance **Sanctuary Beta** réussies. Minecraft
26.3 finale, Loader 0.19.5, Fabric API 0.160.5+26.3. Les 923 fichiers personnels
et réglages suivis sont inchangés ; sauvegardes restées fermées. Copie préalable
des fichiers remplacés : `sanctuary-backups/before-beta.110/`.
## beta.105 — archives locales
Saisons et neige, chapeaux vivants, molette du fût et panoramas avec shader.
[Contrat, tests et empreintes](living-hats-seasons-beta105.md).
Archives normales/Test construites en copie isolée pour préserver le chantier
beta.106 simultané. Aucun déploiement ni publication du canal.
## beta.104 — archives locales
Contraste des argiles grise et noire et rangement beta.103.
[Validation](clay-contrast-beta104.md). Packs normal/Test locaux, aucun déploiement.
## beta.103 — archives locales
Rangement créatif par séries de 16. [Détails](colored-bricks-order-beta103.md).
Packs normal/Test locaux ; aucune publication ni installation personnelle.
## beta.102 — archives locales
Briques et argiles des seize couleurs, Minecraft 26.3. Les nouvelles textures
sont embarquées dans le mod ; le resource pack beta.090 reste compatible.
[Contrat et validation](colored-bricks-beta102.md).
Aucune publication du canal ni installation personnelle.
## beta.101 — archives locales
Correctif datterrissage des familiers volants, Minecraft 26.3, resource pack
beta.090 inchangé. [Validation](flying-landing-beta101.md).
Aucune publication du canal ni installation personnelle.
## beta.100 — archives locales
`Sanctuary-beta.100.mrpack` et `Sanctuary-Test-beta.100.mrpack` : statues
conditionnées aux découvertes et promenade des familiers. Minecraft 26.3,
resource pack beta.090 inchangé. Voir [validation](exploration-familiers-beta100.md).
Aucun canal publié ni déploiement personnel pour cette livraison.
## beta.099 — Catalogue et Progression
Packs normal/Test locaux pour Minecraft 26.3 finale.
[Contrat, génération et vérifications](catalogue-progression-beta099.md).
Les anciennes salles sauvegardées restent inchangées. Aucun canal ou instance
personnelle modifié.
## beta.098 — Construction créative
Packs normal/Test locaux pour Minecraft 26.3 finale. Ajout du bouton créatif
pour construire le plan du Métabli. [Contrat](creative-construction-beta098.md).
Aucun canal ni instance personnelle modifié.
## beta.097 — Temples et plans texturés
Packs normal/Test locaux pour Minecraft 26.3 finale. Présence des temples de
secours et aperçu avec les textures du pack actif.
[Contrat et vérifications](temple-crash-beta097.md).
Le rapport initial concerne beta.089 sur 26.3-pre-2 ; le correctif cible la
version 26.3 finale du dépôt. Aucun canal ni instance personnelle modifié.
## beta.096 — Métabli
Les packs locaux normal/Test ajoutent latelier et séparent son catalogue des
commandes de construction accessibles avec K. Minecraft 26.3, dépendances et
pack intégré beta.090 conservés. [Détails et vérifications](metabli-beta096.md).
Aucun canal publié ni instance personnelle modifié.
## beta.095 — Texture de la tige des super pistons
Les packs locaux normal/Test corrigent quatre modèles de la tige centrale.
Minecraft 26.3, dépendances et pack intégré beta.090 conservés.
[Détails et vérifications](piston-shaft-beta095.md).
Aucun canal publié ni instance personnelle modifié.
## beta.094 — Fourneau allumé
Les packs locaux normal/Test ajoutent les braises de façade et corrigent les
particules du Fourneau. Minecraft 26.3, dépendances et pack de textures intégré
beta.090 conservés. [Détails et vérifications](fourneau-allume-beta094.md).
Aucun canal publié ni instance personnelle modifié.
## beta.093 — Recherche créative
Les packs locaux normal/Test beta.093 corrigent laccès graphique depuis les
infobulles indexées en arrière-plan. Cible Minecraft 26.3 finale ; le rapport
initial provenait de beta.089 sur 26.3-pre-2. Dépendances et textures inchangées.
[Diagnostic et vérifications](tooltip-crash-beta093.md). Aucun canal publié
ni instance personnelle modifié.
## beta.092 — Clic molette des super pistons
Les packs locaux normal/Test beta.092 corrigent la sélection des composants
@@ -1766,3 +2028,32 @@ sauvegardes personnelles sont restées fermées pendant les essais et la synchro
Références officielles : [installation packwiz](https://packwiz.infra.link/tutorials/installing/packwiz-installer/),
[commandes Prism](https://prismlauncher.org/wiki/help-pages/custom-commands/),
[bootstrap v0.0.3](https://github.com/packwiz/packwiz-installer-bootstrap/releases/tag/v0.0.3).
### beta.107 — Œufs, essai isolé
Archives locales normal/Test vérifiées : [contrat](spawn-eggs-acquisition-beta107.md).
Construites sur beta.105 dans `sanctuary-eggs-beta107` ; le Statuaire beta.106,
en développement parallèle, nest pas embarqué. Les sources des œufs sont aussi
présentes dans le répertoire principal. Aucun canal, instance Prism, serveur ou
sauvegarde personnelle mis à jour.
### beta.108 — Neige en volume, essai isolé
Archives locales normal/Test vérifiées : [contrat](snow-accumulation-beta108.md).
Construites sur beta.107 dans `sanctuary-snow-beta108`, avec les œufs et un
plafond de neige confirmé à deux blocs. Statuaire beta.106 reste dans sa livraison
séparée. Sources de neige aussi présentes dans le répertoire principal, sans
modifier ses compteurs beta.106. Aucun canal, instance Prism, serveur ou
sauvegarde personnelle mis à jour.
### beta.109 — Fonte saisonnière, essai isolé
Archives locales normale/Test vérifiées. [Contrat et vérifications](seasonal-snow-melt-beta109.md). Base beta.108, avec
accumulation et œufs ; Statuaire beta.106 reste une livraison séparée. Les
nouveaux dépôts sont suivis dans les chunks et fondent progressivement selon
la saison. Les blocs antérieurs et les constructions sont conservés.
Assemblage dans `sanctuary-melt-beta109` ; sources aussi intégrées au répertoire
principal sans modifier son chantier et ses compteurs beta.106.
Aucune publication du canal, installation Prism ou sauvegarde personnelle.
+48
View File
@@ -0,0 +1,48 @@
# beta.095 — Texture du pilier des super pistons
La tige centrale utilise seulement le carré de bois central de la grande plaque
`sanctuary:block/super_piston/top`, pour les super pistons normaux et gluants.
Le prélèvement reste dans les pixels 16 à 32 de l'image originale de 48 × 48,
sans les bordures. Les UV suivent les dimensions réelles du pilier, à un pixel
par unité de modèle, sans étirement. Cela couvre aussi les segments courts dans
le socle ouvert et derrière la tête.
Les grandes plaques, le corps et les textures originales fournies restent
inchangés. La course, les collisions et les six orientations ne changent pas.
Seuls quatre modèles JSON et leur générateur sont modifiés ; aucun code Java,
identifiant ou format de sauvegarde n'évolue. La référence à la texture
Sanctuary respecte le pack de ressources actif. Aucune nouvelle image ni dépendance ajoutée.
Régénération : `python3 tools/generate-super-piston-models.py`.
## Vérifications et livraison
Le parcours client natif `Piston087ClientChecks` a réussi en 1 min 22 s
(`build/piston095-client.log`), dans un nouveau monde plat de développement,
graine 87 : six directions, normal/gluant, déplacement et retour, collisions,
charges de 108/109, obstacles, inventaires, adhérence, déchargement/rechargement
et démontage. Aucun avertissement de référence de texture manquante.
Les captures horizontales et verticales ont été inspectées dans
`build/piston095-evidence/`, notamment `0015_piston087-normal-face-open.png`
et `0001_piston087-normal-up.png`. Le bois est à la même densité que la plaque.
Le contrôle des quatre modèles vérifie que tous les prélèvements restent dans
le carré central 1632 pixels et que leurs dimensions en pixels correspondent
exactement à celles de leurs faces, raccords courts inclus. Géométrie, autres
faces et textures originales identiques à beta.094. Le générateur est reproductible.
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` a réussi
en 2 min 28 s, 124 tâches (`build/piston095-check.log`).
Les deux archives normal/Test ont été exportées et vérifiées : version beta.095,
Minecraft 26.3, même JAR Sanctuary embarqué. Les classes Java, les autres assets,
JEI et les archives beta.094 sont inchangés. Le reçu et les empreintes sont dans
`build/piston095-artifact.json` ; `build/verify-piston095.py` reproduit ces contrôles.
`git diff --check` passe.
[Pack normal](../build/Sanctuary-beta.095.mrpack) ·
[Pack test](../build/Sanctuary-Test-beta.095.mrpack).
Le serveur dédié GameTest est exclu (`-x :sanctuary:runGameTest`) pour respecter
le choix antérieur de tests client uniquement. Aucun déploiement dans une
instance personnelle, changement de monde ou publication du canal packwiz.
+128
View File
@@ -0,0 +1,128 @@
# Préparation locale des ombres portées pixélisées (ancien numéro beta.122)
Ticket du 17 septembre 2026, branche locale `codex/pixel-shadows-beta122`.
Cette préparation n'a jamais été publiée sous le numéro beta.122, déjà utilisé
par une autre livraison. Elle est intégrée au socle beta.126 et livrée en
[beta.127](shader-beta127.md). Les chemins et mesures ci-dessous décrivent
les essais historiques dans le dossier de préparation.
Demande : ajouter aux réglages de Sanctuary · Vanilla Light des ombres portées
pixélisées, avec une finesse et une distance réglables.
## Utilisation
Dans **Options → Shaders**, activer **Sanctuary · Vanilla Light**, puis
**Ombres portées pixélisées**. L'option est désactivée par défaut pour conserver
le coût et le rendu des installations existantes. Les choix sont :
- finesse de **8, 16 ou 32 pixels par bloc**, défaut 16 ;
- distance de **16, 32 ou 64 blocs**, défaut 32.
Ces préférences sont conservées dans `config/sanctuary-shaders.json`, avec les
anciens champs de couleur. Leurs valeurs numériques sont ramenées aux choix
admis au chargement. Les libellés et explications sont disponibles en FR/EN.
Les réglages s'appliquent sans recharger les ressources.
## Rendu et périmètre
Une carte de profondeur rend les formes d'occultation des blocs solides chargés
autour de la caméra, indépendamment de leur présence à l'écran. Les dalles et
escaliers conservent leurs formes. Le cache est invalidé par les changements de
blocs, les paquets de chunks et les reconstructions du moteur natif.
La direction solaire vient du ciel natif extrait par Minecraft, et respecte
ainsi l'angle solaire de Sanctuary. L'effet s'atténue sous la pluie, à l'horizon,
dans la brume et à la limite de sa portée. Il s'arrête la nuit, dans les dimensions
sans soleil, dans les fluides et lorsqu'un moteur externe suspend Vanilla Light.
La profondeur du monde est lue directement par le GPU après le rendu du monde,
avant la main et l'interface. L'inverse de la projection réelle inclut les effets
de caméra. Les points recevant l'ombre sont arrondis sur une grille ancrée dans
le monde ; le plan de la face est conservé pour limiter les auto-ombres.
La carte solaire utilise également une origine alignée sur ses texels.
Le HUD, la main, les étoiles et les captures panoramiques techniques gardent leur
chemin de rendu. Aucun niveau de lumière de gameplay, règle serveur, sauvegarde
ou générateur n'est changé. Aucune dépendance de rendu n'est ajoutée.
Cette première version couvre les **formes d'occultation des blocs** : les entités
conservent leurs ombres natives ; les feuillages ajourés, modèles non occultants,
block entities animés, eau et verre ne projettent pas de nouvelles silhouettes.
Les transparences et particules déjà composées peuvent recevoir l'assombrissement
du terrain derrière elles. Il n'y a pas d'ombres de lune ou de torches, ni de
séparation de l'éclairage local émissif dans cette passe d'assombrissement.
## Coût et cycle de vie
Les maillages sont mis en cache par section de 16³, avec au plus quatre sections
commencées par image et un budget souple de 3 ms pour leur construction. Les
ombres apparaissent progressivement pendant la préparation après activation ou
déplacement. Le volume des obstacles part de la portée choisie avec une marge de huit blocs,
puis s’étend vers le soleil sur deux fois cette portée, arrondi aux sections.
Les obstacles au-delà de ce volume ou dans des chunks non chargés ne sont pas
représentés. Les mises à jour de lumière seule ne reconstruisent pas les formes.
Le cache de sommets est plafonné à 96 Mio et omet les formes pathologiques qui
dépassent ses limites. Les textures de profondeur font entre 512 et 4 096 pixels
de côté selon finesse et portée. Le coût exact dépend de la scène et du GPU.
Les ressources sont libérées à la désactivation, au changement de monde, à la
reconstruction des ressources et à l'arrêt du client. Cette livraison est locale :
aucun avancement du canal packwiz ni installation personnelle ne fait partie du
ticket.
## Vérification
Validation native sur **Minecraft 26.3, Java 25, macOS / Apple M1, OpenGL 4.1**,
dans un nouveau monde plat de développement, graine **122** :
- Comparaison GPU activation/désactivation sur une plateforme blanche avec
pilier, dalle et escalier : **920 échantillons assombris**, 63 412 inchangés,
aucun éclairci sur les 64 332 échantillons de sol retenus.
- Pilier entièrement hors du frustum : **42 048 échantillons** restent dans
son ombre. Le retrait du pilier restitue la lumière sur **42 048 / 42 048**
échantillons et préserve les 23 616 échantillons déjà éclairés. Sa repose
restitue la même ombre, sans désactiver le cache entre ces manipulations.
- Les trois finesses et les trois portées sont appliquées en direct ; les cartes
GPU sont redimensionnées aux dimensions attendues. Désactiver l'option libère
la texture et ne soumet plus de passe d'ombres.
- À minuit, activer ou désactiver l'option donne la même image et aucune passe
solaire n'est soumise. Au retour du jour, le rendu reprend.
- Le rechargement des ressources restitue la même image. Les préférences
persistent à la relecture du fichier et leurs valeurs extrêmes sont bornées.
- Menus FR/EN inspectés en capture ; les libellés tiennent dans les contrôles
natifs à la taille de test. Les vues de finesse 8/16/32 sont conservées.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryPixelShadows122ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Succès en **1 min 17 s**, marqueur `PIXEL_SHADOWS122_CLIENT_PASS` dans
`build/pixel-shadows122-client.log`. Captures inspectées dans
`build/pixel-shadows122-evidence/final/`. Les pixels sont relus uniquement dans
le test ; le code de rendu de production n'effectue aucune lecture CPU de GPU.
Les premiers démarrages ont permis de corriger les identifiants de pipelines,
la directive GLSL `#include` attendue par 26.3 et deux libellés répétés du menu.
La commande de livraison est :
```sh
./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest
```
Le serveur GameTest dédié est exclu, conformément au refus de son EULA déjà
consigné dans les livraisons précédentes. Le test graphique utilise le serveur
intégré de son monde neuf. Aucun monde personnel n'est ouvert.
La construction finale **réussit en 2 min 18 s** (126 tâches), journal
`build/pixel-shadows122-build-final.log`. Le contrôle des archives confirme
**868 sources Java** et **1 869 classes** identiques aux sorties construites,
les ressources GLSL/FR/EN exactes dans le JAR et le template, et le même JAR
dans les profils packwiz normal et Test. Aucune fixture cliente n'est embarquée.
Reçu : `build/pixel-shadows122-artifact.json`.
Artefacts locaux : `mods/sanctuary/build/libs/sanctuary-beta.122.jar`,
`build/packwiz/`, `build/packwiz-test/` et `build/Sanctuary-Template-beta.122.zip`.
SHA-256 du JAR :
`66987681cede1d2adc1dda449078e8fa85310eb2c3fecd7a905d772ce75739c9`.
Pas de mesure de FPS, d'essai Windows/Vulkan, de pack Iris ni de silhouettes
animées revendiqués. Le shader reste un assombrissement graphique appliqué au
rendu opaque et déjà composé, avec les limites de transparence décrites plus haut.
+125
View File
@@ -0,0 +1,125 @@
# RP-02 — Clé dorée et pack personnel — beta.121
Contrat du 17 septembre 2026, branche `codex/resourcepack-template-beta121`.
Le créateur fournit `golden_wrench.png` et valide un bouton qui crée une copie
personnelle complète, modifiable et jamais écrasée par les mises à jour.
## Résultat
La clé dorée reprend exactement le PNG fourni, 16 × 16, sans conversion ni
redessin. Le modèle reste un item plat `minecraft:item/handheld`. Empreinte :
`a97570908db75caf8e6fc2f5ecabf12c54fd99bfea7be634df66c4310dca22af`.
Le même fichier est fourni par le mod et par « Textures Sanctuary » ; désactiver
le pack intégré conserve donc aussi la nouvelle clé.
Dans **Options → Packs de ressources**, **Personnaliser Sanctuary** crée
`resourcepacks/Sanctuary-Custom/`. Le bouton devient **Ouvrir ma copie**.
La copie contient toutes les ressources client de Sanctuary : textures, modèles,
étiquettes d'items, blockstates, sons, shaders, langues et remplacements Minecraft
présents dans le pack intégré. Celui-ci prend priorité sur les assets de base
lors de l'export. Un guide FR/EN, la licence et les crédits accompagnent la copie.
Le joueur active sa copie avec les commandes natives et la place au-dessus des
packs qu'il veut remplacer. Créer la copie ne change ni l'ordre ni la sélection.
Une modification se recharge avec F3 + T ; désactiver le pack retrouve les
ressources fournies par le mod. L'écran de sélection des datapacks n'est pas modifié.
La copie est un **instantané**. Elle ne se synchronise pas avec les mises à jour :
les fichiers personnalisés restent toujours intacts. Le guide recommande de
supprimer les fichiers non personnalisés pour laisser les prochaines ressources
du mod s'appliquer. Pour repartir d'un instantané récent, renommer le dossier
existant puis créer une nouvelle copie. Aucun fichier utilisateur n'est écrasé.
La copie s'effectue hors du fil de rendu, dans un dossier temporaire voisin,
puis le résultat complet rejoint la liste native. Une erreur de copie est
signalée par le bouton et le journal du jeu ; un chemin occupé par un fichier
n'est pas remplacé. Aucune extraction au démarrage ni pendant les mises à jour.
## Distribution
`./gradlew assembleResourceTemplate` produit
`build/Sanctuary-Template-beta.121.zip`, un pack personnel complet à décompresser
dans `resourcepacks/Sanctuary-Custom/`. `assemblePack` le produit aussi.
`build/Sanctuary-Resource-Pack-beta.121.zip` reste l'export du pack graphique
intégré ; son manifeste versionné contient 151 fichiers et les images historiques
sont conservées. L'identifiant intégré `sanctuary:textures` reste stable et son
activation reste contrôlée par le joueur.
Le template ne contient pas tous les assets vanilla ni ceux des autres mods.
Il ne contient pas les recettes, règles serveur ou sauvegardes. Il conserve
les crédits et conditions des ressources d'origine. Aucune génération, aucun
identifiant de bloc et aucun format de sauvegarde ne changent.
## Vérifications
Le parcours `ResourceTemplate121ClientChecks` passe en **35 s** sur le client
Minecraft 26.3 macOS, sans ouvrir de monde :
- Bouton natif et disposition FR/EN aux échelles 2 et 3, captures relues.
- Export complet de **1 202 fichiers**, empreintes identiques aux ressources
sources après application des priorités du pack intégré.
- PNG fourni vérifié par SHA-256 et affiché avec sa transparence.
- Création sans changer la sélection ni l'ordre des packs.
- Modification d'une texture et ajout d'un fichier personnel : aucun octet
réécrit lors d'un second export ni au rechargement des ressources.
- Priorité réelle de la texture personnalisée, puis retour à la clé fournie
après désactivation. Activation et désactivation conservées à la redécouverte.
- Chemin occupé par un fichier refusé sans le modifier ; aucun bouton ajouté
à l'écran des datapacks.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryResourceTemplate121ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Marqueur `TEMPLATE121_PASS` dans `build/template121-client.log` ; captures et
empreintes de la copie dans `build/template121-evidence/`. Le premier essai
avait échoué dans l'aperçu du test qui créait un ItemStack avant connexion à
un monde ; cet aperçu affiche maintenant directement la texture résolue.
Le modèle plat existant n'est pas modifié. Pas d'essai Windows ni d'ouverture
automatisée du gestionnaire de fichiers. Le GameTest serveur dédié reste exclu
conformément au refus antérieur de son EULA.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 10 s**, 126 tâches. Les sources JAR correspondent aux sources
Java du dépôt. La comparaison avec beta.120 limite les différences de production
aux deux nouvelles classes, au hook d'interface, aux libellés et aux ressources
prévues (12 entrées). Aucun changement de gameplay, de données serveur ou de
monde. Les packs normal/Test contiennent le même JAR Sanctuary, sans fixture
ni sauvegarde. Le ZIP du template et l'export natif ont exactement les mêmes
1 202 fichiers et empreintes. Le pack intégré conserve ses 151 fichiers vérifiés.
Reçu : `build/template121-artifact.json`. Artefacts locaux validés :
- `Sanctuary-beta.121.mrpack` : 10261791 octets, SHA-256
`040d68f342155120fa21e0ffcdf34aa38e3225c78862960536622de53ed9fd9f`.
- `Sanctuary-Test-beta.121.mrpack` : 10280716 octets, SHA-256
`7f5bbd4e13aea4c245d072725c094ca64826ee8debc8607623783c114d437685`.
- `Sanctuary-Resource-Pack-beta.121.zip` : 3757952 octets, SHA-256
`bbf3c18ea6abc64d951911215975cb6c48c7fc5e0246f6e5f5192a3d061c211d`.
- `Sanctuary-Template-beta.121.zip` : 4315131 octets, SHA-256
`6fad376c53474fa8bcb0a2e6ae4c13ea61ab91bdd78735be443032d485ed541a`.
JAR Sanctuary : SHA-256
`4276f4fc9834805e3e6d12cd19adba7837e3007656b77f31d525c7b976f9925d`.
## Publication et installation
La [release beta.121](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.121)
est publiée depuis `d9b3dd6bedf32448f8c6e5bfa4da10cb69968bc7`. Tag et artefacts immuables,
téléchargements publics vérifiés. Canal packwiz :
`e3e6572da9288fbbcfa36bf6b7dd3aa0d1c54f5e`.
Deux synchronisations isolées puis deux dans **Sanctuary Beta** réussissent.
Un seul JAR Sanctuary beta.121 est actif ; le second passage est identique.
Les **923 fichiers personnels et réglages suivis** restent inchangés,
y compris les packs de ressources. Aucune copie personnelle n'est créée par
la mise à jour : chaque joueur utilise le bouton quand il le souhaite.
Aucun monde personnel ouvert. Copie préalable dans
`sanctuary-backups/before-beta.121/` de l'instance existante ; reçus
`build/template121-isolated.json` et `build/template121-prism.json`.
- [Template personnel complet](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-Template-beta.121.zip).
- [Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-beta.121.mrpack).
- [Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-Test-beta.121.mrpack).
+93
View File
@@ -0,0 +1,93 @@
# SCULPT-10 — Une boîte par sculpture — beta.124
Contrat du 17 septembre 2026, branche `codex/sculpture-bounds-beta124`.
Le créateur choisit **une seule boîte pour la sélection et les collisions**.
Ce ticket remplace la forme physique détaillée de beta.122.
La boîte englobe exactement les dimensions occupées par la sculpture, après
orientation et ancrage au centre ou au bord. Elle suit toujours la grille 1/16,
y compris les tailles impaires et les modèles comportant des marges vides.
Les trous et petits détails restent visibles mais n'ont plus leurs propres
surfaces de collision : on ne grimpe plus sur leurs petites marches. Une arche
se sélectionne et bloque désormais sur tout son volume englobant.
La pose utilise la même boîte avant la création de l'entité de bloc : elle
refuse une intersection avec le joueur, y compris dans les trous visuels.
Les petites sculptures laissent libre le reste du bloc autour de leur boîte.
La forme reste mise en cache par modèle et état ; elle ne se recalcule qu'après
un changement de modèle, d'orientation ou d'ancrage. Les requêtes physiques et
le contour partagent cette même forme native.
Le calcul parcourt les cellules une seule fois, sans créer de copies tournées
ni de grille de collisions 16³. Le contour émet toujours douze arêtes.
Il s'agit d'une simplification du travail géométrique ; aucun gain de FPS global
n'est annoncé sans mesure sur une scène représentative.
Le rendu des sculptures et les règles de lumière de beta.122 restent inchangés :
pas d'occlusion de cube plein, pas d'émission artificielle de lumière. Aucun
identifiant, format d'item ou de sauvegarde ne change. Les anciennes sculptures
utilisent la nouvelle forme à partir de leurs données existantes, sans réécriture
des mondes ni régénération de chunks. L'atelier et les clés restent inchangés.
## Vérifications
Le parcours `Sculpture122ClientChecks`, adapté au nouveau contrat, passe en
**39 s** sur Minecraft 26.3, client macOS et serveur intégré jetable de graine 122.
- Sélection native de tout le volume, y compris le trou visuel d'une arche ;
contour rectangulaire relu dans la capture.
- Même boîte physique et de sélection sur client et serveur ; intérieur rempli,
dimensions exactes sans profondeur artificielle de bloc entier.
- **80 cas** : boîte unique comparée aux limites du maillage réellement émis,
douze arêtes exactement, même objet réutilisé lors des requêtes répétées.
Arche, marges vides, cube de 4 096 cellules et voxel unique, dans les quatre
orientations et cinq ancrages.
- **5 120 placements** de rendu toujours alignés sur la grille entière.
- Pose refusée dans le joueur, y compris entre deux piliers séparés ; petite
sculpture autorisée à côté du joueur, hors de sa boîte physique.
- Cache renouvelé après changement d'orientation/ancrage ou de modèle ;
conservation après sauvegarde/reconnexion.
- **75 sculptures superposées** : ciel à 15 ; toit de pierre témoin sombre ;
lumière de bloc transmise avec atténuation normale ; pièce close à 0.
Marqueurs dans `build/sculpture124-client.log`, notamment
`SCULPTURE124_BOUNDS_PASS 80`, plus les contrôles de régression de beta.122.
Captures relues dans `build/sculpture124-evidence/`. Aucun essai Windows,
client distant ou shader tiers ; le coût des boîtes est simplifié sans benchmark
FPS global.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 9 s**, 126 tâches (110 exécutées, 16 à jour).
Les sources des JAR correspondent aux sources du dépôt. La comparaison avec
beta.123 limite les différences de production aux trois classes de
`SculptureGeometry` ; ressources, clés, interfaces, données et éclairage restent
identiques. Les packs normal/Test contiennent le même JAR, sans sauvegarde
ni fixture. Le template personnel conserve exactement les ressources de beta.123.
- Pack normal : 10 271 059 octets, SHA-256
`2075e4ac7a7e7e2effcd82586d46769607ca1a67e8d9110b000fa378d1bc5506`.
- Pack Test : 10 289 985 octets, SHA-256
`88263e6896590ea9694b18084666e7234e934064e8d7d596d34866a68cc5ea23`.
- JAR Sanctuary : SHA-256
`b3969b1a3738b8a5d332b2083db0dd722900239f73035c335b738f70328d43f1`.
Reçu local : `build/sculpture124-artifact.json`. Les packs normal/Test et le
template sont aussi copiés, avec vérification octet pour octet, dans le dossier
habituel `sanctuary-beta/build/`.
Le GameTest dédié reste exclu conformément au refus antérieur de son EULA.
Aucun monde personnel n'est ouvert par les essais.
## Publication et installation
La [release beta.124](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.124)
est publiée depuis `00179e30ef0b45ef98378bc164a7810552dd1bdd`.
Le tag exact et les artefacts sont immuables, leurs téléchargements publics
vérifiés. Canal packwiz : `67ebcc7d07f8b75e8988f6099e3d6e5704f123fb`.
Deux synchronisations isolées puis deux dans **Sanctuary Beta** réussissent.
Un seul JAR Sanctuary beta.124 est actif, conforme au hash vérifié ; le second
passage ne change rien. Les **923 fichiers personnels et réglages suivis**
restent inchangés, sans ouverture de monde personnel. Copie préalable dans
`sanctuary-backups/before-beta.124/` de l'instance existante.
Reçus : `build/sculpture124-isolated.json` et `build/sculpture124-prism.json`.
+84
View File
@@ -0,0 +1,84 @@
# STAT-07 — Pose centrée des sculptures — beta.119
Contrat du 17 septembre 2026, branche `codex/sculpture-center-beta119`.
Viser le carré central de 8 × 8 pixels de la face supérieure ou inférieure
du bloc place la sculpture au centre. Les limites de cette zone sont incluses.
Autour, le bord horizontal le plus proche reste choisi ; une pose latérale
continue à s'appuyer contre le bloc support. L'orientation suit le regard.
L'aide du Clay Workshop explique les deux possibilités en français et anglais.
Le centrage utilise l'emprise réelle des voxels après rotation, en ignorant les
marges transparentes. Tous les sommets restent sur la grille de 1/16 de bloc.
Une dimension impaire ne peut pas être parfaitement symétrique dans un bloc de
16 pixels sans décaler ses voxels : elle prend donc le centrage entier le plus
proche, avec un pixel de différence entre les deux marges. Aucune position à
un demi-voxel n'est introduite.
## Compatibilité
La pose réutilise `anchor=center`, déjà présent depuis beta.113. Aucun nouvel
état, identifiant, composant ou format de sauvegarde. Les sculptures existantes
gardent leur ancrage ; casser et reposer choisit l'emplacement visé. Le rendu,
les couleurs, les chapeaux et les collisions restent inchangés. Aucun parcours
de monde existant, aucune régénération. Client et serveur doivent utiliser
ensemble la nouvelle version pour le choix de pose.
## Vérifications et livraison
Le parcours natif `Statuary106ClientChecks`, avec `Sculpture113ClientChecks`,
passe en **54 s** sur Minecraft 26.3/macOS :
- Vrais clics de pose centrée dans quatre orientations, sur client et serveur,
puis pose contre les quatre bords sans modifier les couleurs du modèle.
- Limites incluses et points immédiatement de part et d'autre de la zone,
faces supérieure/inférieure et positions positives/négatives ; faces
latérales toujours calées contre le support.
- 5 120 combinaisons de largeur/profondeur, orientation et ancrage : géométrie
effectivement rendue sur la grille exacte, emprise sans dépassement,
centrage pair/impair et marges transparentes.
- Sauvegarde/reconnexion des sculptures centrées et aux bords, sans fichier
source ; rotations/miroirs, anciens états, fabrication et hotbar vérifiés.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryStatuary106ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Marqueurs `SCULPTURE119_PASS`, `STATUARY113_PASS` et
`SCULPTURE113_GEOMETRY_PASS` dans `build/sculpture119-client.log`.
Captures conservées dans `build/sculpture119-evidence/`, vue centrée relue.
Monde plat jetable de graine 106 ; aucun monde personnel ouvert. Pas de
validation Windows ni de test depuis deux ordinateurs.
Le GameTest dédié reste exclu conformément au refus antérieur de son EULA ;
le parcours natif utilise le serveur intégré.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 24 s**, 125 tâches. Les sources JAR correspondent aux sources
du dépôt ; seuls `SculptureAnchor.class` et le texte d'aide FR/EN diffèrent
parmi les classes et ressources de production de beta.118. Les deux packs
contiennent le même JAR, sans monde ni fixture de test.
- Sanctuary-beta.119.mrpack : 10216447 octets, SHA-256
`ce29702eaaac7301c77fb6af63a76c6bc1c0c704313ce1b8243c95b5fe59d893`.
- Sanctuary-Test-beta.119.mrpack : 10235369 octets, SHA-256
`a1a2845f9678b1144d7d9eab4867970cc1776fe0ad5d51722c8110b7c1d4c682`.
- JAR Sanctuary : SHA-256
`d353bd89b16c53ee1ee48836fa2ff4b311249b280992300c28bf03048d805bff`.
Reçu local : `build/sculpture119-artifact.json`.
## Publication et installation
La [release beta.119](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.119)
est publiée depuis `3fd64e1f2a77ea2584b087d6339cf605d8ee0e9b`. Le tag exact et les
artefacts sont immuables ; leurs téléchargements publics sont vérifiés.
Le canal packwiz avance à `5e823af7807f524f6394acad402fe62328c7e4ab`.
Deux synchronisations isolées puis deux dans **Sanctuary Beta** réussissent.
Un seul JAR Sanctuary beta.119 est actif ; le second passage est identique.
Les **923 fichiers personnels et réglages suivis** restent inchangés, sans
ouvrir de monde personnel. Copie préalable dans
`sanctuary-backups/before-beta.119/` de l'instance existante. Reçus locaux :
`build/sculpture119-isolated.json` et `build/sculpture119-prism.json`.
+102
View File
@@ -0,0 +1,102 @@
# beta.116 — Sculptures à partir des textures de blocs
Ticket sur `codex/sculpture-materials-beta116`, comprenant aussi le correctif
Fourneau beta.115 dans la livraison commune.
Le Clay Workshop accepte les objets-blocs comme matière. Le client prélève une
palette bornée dans la texture du bloc issue du pack actif, avec sa teinte, puis
le modèle conserve sa géométrie et transpose ses valeurs claires/sombres dans
cette palette. L'aperçu et le résultat utilisent le même calcul. Un bloc est
consommé par sculpture ; les neuf cases de hotbar restent les seules accessibles.
## Contrat des données et du réseau
Les sculptures existantes conservent exactement leurs voxels sauvegardés :
schéma 1 inchangé, identifiants `sanctuary:*` stables. Les nouvelles sculptures
stockent elles aussi leurs couleurs finales dans ce format. Aucun chargement
ne recolore les objets ou constructions existants. Orientation et ancrage
beta.113 inchangés, sans migration ni modification d'un monde personnel.
La sélection du menu ajoute l'identité de la matière et une palette de 256
couleurs opaques au maximum. Ce sont des données d'apparence fournies par le
client, comme le modèle déjà importé ; elles n'octroient ni objets ni propriétés
de bloc. Le serveur vérifie le menu, son jeton, l'accès à l'atelier, les limites
et l'objet effectivement présent. Il applique lui-même la palette et facture
chaque résultat. Un changement de matière invalide immédiatement l'ancien
résultat jusqu'à réception de la sélection correspondante. Les deux côtés
doivent utiliser beta.116. Aucun nouveau format de sauvegarde.
## Vérifications natives
`Sculpture116ClientChecks` passe en 1 min 7 s, sur un nouveau monde plat de
Minecraft 26.3, graine 116, puis la régression complète de l'atelier :
- Les 1 207 objets-blocs enregistrés ont une palette opaque, non vide et bornée.
- Fabrication payée avec pierre, planches de chêne, or, laine rouge, verre,
feuilles de chêne, argile et ardoise ; aperçu identique au résultat serveur.
- Conservation des tons clairs/sombres du modèle et de ses coordonnées, sans
convertir les 16 argiles colorées en une simple couleur unie.
- Une matière différente efface aussitôt l'ancien résultat ; une sélection
périmée ou un jeton incorrect ne fabrique rien et ne consomme aucun objet.
- Les conteneurs contenant des objets sont refusés ; ils peuvent servir de
matière une fois vidés. Les objets qui ne sont pas des blocs sont refusés.
- Bornes et opacité des palettes, véritable rechargement des ressources,
neuf cases de hotbar, stock plein, consommation exacte et restitution.
- Poses selon les quatre regards, accroche au bord, grille de voxels,
casse/repose, sauvegarde et reconnexion après suppression du fichier GLB.
- Interface FR/EN aux échelles 2/3, génération de plan au Métabli inchangée.
Marqueurs `SCULPTURE116_PALETTES`, `SCULPTURE116_MATERIALS_PASS`,
`SCULPTURE116_PASS` et régressions 106/111/112/113 dans
`build/sculpture116-client.log`. Captures relues dans
`build/sculpture116-evidence/`. Un libellé est raccourci en « Bloc » pour éviter
le contact avec « Sculpture » dans le cadre compact.
La palette vient de la texture représentative du modèle de bloc du pack actif,
avec la teinte locale ; une texture animée utilise sa première image. Les blocs
sans texture lisible utilisent leur couleur de carte. Il s'agit d'un transfert
de couleurs sur les voxels opaques, sans transparence, émission ou fonction
spéciale du bloc d'origine. Les sculptures existantes gardent leurs couleurs.
Essais natifs macOS avec serveur intégré ; pas de client Windows ni de test
LAN à plusieurs joueurs. Le serveur dédié de GameTest reste exclu selon le
refus antérieur de son EULA. Aucun monde personnel ouvert.
La régression native du Fourneau passe également dans beta.116, en 42 s :
`build/sculpture116-furnace.log`, captures FR/EN dans
`build/sculpture116-furnace-evidence/`. Les 27 cases, le glisser-déposer, les
combustibles et les états de cuisson restent conformes au ticket beta.115.
## Construction et archives
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussit
en 2 min 23 s : 125 tâches, dont 105 exécutées. Sources Java concordantes avec
les JAR sources, versions alignées et intégrité des deux MRpack vérifiées.
Le JAR conserve à l'identique le correctif Fourneau beta.115 ; seuls les
classes de matière de sculpture et libellés FR/EN changent dans la production.
Les classes de test et modèles GLB de test sont absents des archives.
- `Sanctuary-beta.116.mrpack` : 10189831 octets, SHA-256
`7df04a6e2a57e76a55ca57bd7520b05cbb573392690b401b868837498807d252`.
- `Sanctuary-Test-beta.116.mrpack` : 10208754 octets, SHA-256
`f4c9293e20384b05366d092fc7ee1abea275940a02373ee4036f73f0caa43747`.
- JAR Sanctuary :
`76a9205783c8b18506c32c9eef6c481d95fd3ba0746a2bdd558a194534a51a32`.
Reçu local : `build/sculpture116-artifact.json`.
## Publication et instance
La [release beta.116](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.116)
est publiée avec le JAR, les deux MRpack et l'amorçage Prism. Le tag immuable
désigne `e3ca9fcd6383f7646e4fea7e5098e7097ebb30aa` ; téléchargements publics
et empreintes vérifiés. Le canal packwiz avance au commit
`c741870160f8a21f0a9793f20f0e624ebdbacdb6`.
Deux synchronisations isolées, puis deux dans l'unique instance **Sanctuary
Beta**, réussissent. La seconde passe laisse les fichiers gérés inchangés,
avec un seul JAR Sanctuary beta.116. Minecraft 26.3, Loader 0.19.5 et Fabric
API 0.160.5+26.3 conservés. Les 923 fichiers personnels et réglages suivis
restent identiques ; aucun monde personnel n'a été ouvert. Sauvegarde des
fichiers remplacés dans `sanctuary-backups/before-beta.116/` de cette instance.
Reçus : `build/sculpture116-isolated.json`, `build/sculpture116-prism.json`.
+108
View File
@@ -0,0 +1,108 @@
# SCULPT-11 — Placement, éclats et modèles découverts — beta.125
Contrat du 17 septembre 2026, branche `codex/sculpture-placement-beta125`.
Le point visé choisit neuf positions horizontales (centre, quatre bords et quatre
coins) et trois hauteurs (bas, milieu et haut). Sur un support, la sculpture
repose au bas de son bloc ; sous un plafond, elle touche le haut ; sur une face
latérale, elle suit la hauteur visée et reste contre le support. Les sculptures
restent debout et s'orientent selon le regard horizontal du joueur.
La bande centrale occupe les huit pixels médians, limites incluses. Toute pose
reste sur la grille 1/16 : une dimension impaire utilise le centre entier le
plus proche, jamais un demi-voxel. Rendu, boîte unique de sélection et collision
partagent les mêmes offsets. La lumière conserve les règles de beta.122.
## Compatibilité additive des états existants
Les cinq anciennes valeurs `anchor` gardent exactement leurs noms. Quatre
valeurs de coin sont ajoutées. La nouvelle propriété `vertical_anchor` accepte
`bottom`, `center`, `top` et `original`, valeur par défaut réservée à la
compatibilité. La lecture d'un ancien bloc sans cette propriété conserve
l'ancienne hauteur, même si le modèle a des marges transparentes en bas.
Le bloc possède 144 états : 108 placements nouveaux et 36 états de compatibilité.
Le schéma 1 des sculptures, leurs données de voxels et tous leurs identifiants
restent inchangés. Aucun monde n'est parcouru ou réécrit, aucun chunk régénéré.
Les nouveaux ancrages nécessitent beta.125 ; le retour à une version antérieure
ne garantit pas leur position. Ce contrat autorise l'ajout compatible des états,
pas une migration destructive des sauvegardes personnelles.
## Particules
Les coups et la casse émettent des éclats de la couleur des surfaces voxelisées
réellement affichées, avec leur orientation et leur ancrage. Un coup vise la
surface réelle la plus proche du point sélectionné, même devant un trou visuel.
La casse échantillonne au plus 64 faces ; aucun calcul de texture n'est refait.
Les éclats utilisent une taille de voxel, la gravité et la lumière natives.
Une petite mémoire par monde (128 entrées de 64 faces, validité 40 ticks) conserve les
surfaces si la suppression du bloc précède l'événement de casse. Les autres
blocs gardent leurs particules habituelles.
## Clay Workshop
L'onglet Animaux utilise exactement les découvertes serveur du catalogue de
statues (rencontre, victoire ou mort face à l'espèce). Le menu transmet cette
liste à chaque ouverture, sans dépendre d'une visite préalable au Métabli.
Le modèle natif et sa texture sont capturés localement, voxelisés en miniature,
puis recolorés par la matière choisie. Le résultat reste fabriqué et payé côté
serveur. Les sélections d'espèces non découvertes sont refusées. L'onglet Imports
conserve les fichiers GLB. Aucun nouveau format de sculpture n'est introduit.
Libellés FR et EN.
## Vérifications
Le parcours natif `Sculpture125ClientChecks` passe en **1 min 2 s**, client
Minecraft 26.3 sur macOS et serveur intégré jetable (graines 125 et 122).
- 54 clics réels sur les six faces : centres, bords, coins et hauteurs.
- 432 contextes de pose, coordonnées positives/négatives, quatre orientations,
rotations et miroirs ; 144 états valides.
- 20 anciens états sans `vertical_anchor` conservent leur hauteur et marges.
- Couleur exacte des éclats au point frappé, surfaces réellement occupées,
limite de 64 fragments, événement après suppression et particules normales
d'un bloc de pierre témoin.
- Atelier : découvertes par rencontre, victoire et mort, transmission native
du menu, refus serveur d'une espèce inconnue, trois modèles natifs (vache,
mouton, cochon), paiement d'un bloc en survie et import GLB conservé.
- Sauvegarde et reconnexion avec les nouveaux coins et hauteurs.
- 576 formes comparées aux limites du maillage affiché, boîte unique partagée,
cache réutilisé et 12 arêtes ; 9 216 empreintes orientées sur grille entière.
- 75 sculptures superposées : ciel à 15, lumière de bloc à 13 après traversée,
pièce noire à 0. Pose dans le joueur refusée, même devant un trou visuel.
Marqueurs dans `build/sculpture125-client.log`. Captures de plafond, murs et
atelier relues dans `build/sculpture125-evidence/`. Aucun essai Windows,
client distant ou shader tiers ; trois espèces vérifiées visuellement, sans
prétendre avoir inspecté chaque espèce. Aucun benchmark FPS global.
Le GameTest dédié demeure exclu conformément au refus antérieur de son EULA.
Les mondes de test sont jetables ; aucun monde personnel n'est ouvert.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 16 s**, 126 tâches (110 exécutées, 16 à jour). Les sources
des JAR correspondent exactement au dépôt. La comparaison avec beta.124
limite les différences aux classes de placement, particules et atelier prévues,
aux nouveaux libellés FR/EN, au mixin client et à la source blanche de particules.
Les packs normal/Test partagent le même JAR, sans monde ni fixture ; le template
reproduit les ressources du mod et du pack intégré.
- Sanctuary-beta.125.mrpack : 10286360 octets, SHA-256 `9277e59c640c40392ab3750b402bb840020884878f6ece6978e6c4653e18655e`.
- Sanctuary-Test-beta.125.mrpack : 10305282 octets, SHA-256 `9e3dbfa14b5382ba4eb62a78fa7d2161758c2f0e1fdc50fbd5ed776fd4ce32c5`.
- JAR Sanctuary : SHA-256 `b38b75b900dd8cbe015318c739d5547fd7eac52f4337fa517e98f3f6f0ce3b46`.
Reçu local : `build/sculpture125-artifact.json`. Les deux packs et le template
sont copiés et vérifiés dans le dossier habituel `sanctuary-beta/build/`.
## Publication et installation
La [release beta.125](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.125)
est publiée depuis `4ec5b0d028a23a9f753770988ffba61015e2e49a`.
Tag et artefacts immuables, téléchargements publics vérifiés. Canal packwiz :
`47eb833911a96eef0b13805dafe65b2ab7b95c87`.
Deux synchronisations isolées puis deux dans l'instance **Sanctuary Beta**
réussissent. Un seul JAR Sanctuary beta.125 est actif, conforme au hash vérifié ;
le second passage ne change rien. Les **923 fichiers personnels et réglages
suivis** restent inchangés, sans ouverture de monde personnel. Copie préalable
sous `sanctuary-backups/before-beta.125/`. Reçus :
`build/sculpture125-isolated.json` et `build/sculpture125-prism.json`.
+108
View File
@@ -0,0 +1,108 @@
# SCULPT-09 — Forme physique et lumière — beta.122
Contrat du 17 septembre 2026, branche `codex/sculpture-shape-light-beta122`.
Le créateur demande des bounding boxes adaptées aux sculptures et la suppression
des ombres cubiques visibles lorsqu'on en pose plusieurs côte à côte.
La sélection, le contact avec les entités et les collisions suivent les voxels
occupés de chaque sculpture, y compris les creux. Le rendu et la forme physique
partagent leur placement entier sur la grille 1/16, après orientation et ancrage.
Le centre, les quatre bords et les dimensions impaires gardent leur alignement.
La pose vérifie aussi les voxels portés avant que lentité de bloc existe :
elle refuse une intersection avec le joueur et autorise les espaces libres.
La forme est construite directement sur une grille native 16³ et mise en cache
par entité de bloc, modèle et état ; aucun calcul complet à chaque interrogation.
Les sculptures transmettent la lumière et ne sont plus utilisées comme cubes
pleins par l'occlusion ambiante. Leurs faces restent éclairées par le monde,
sans émission de lumière et sans rendu à luminosité maximale. Cette règle est
commune aux sculptures : l'éclairage natif stocké par bloc ne permet pas une
ombre distincte pour chaque petit voxel. L'atelier d'argile reste inchangé.
Aucun identifiant, blockstate, format d'item ou schéma sauvegardé ne change.
Les formes sont recalculées à partir des voxels existants ; aucune migration,
aucun parcours de monde ni régénération de chunk. Les données inconnues gardent
leur conservation opaque et une petite base sélectionnable pour récupérer l'item.
Le travail de documentation déjà présent dans l'autre branche reste intact.
## Vérifications et livraison
Le parcours `Sculpture122ClientChecks` passe en **37 s** sur Minecraft 26.3,
client macOS et serveur intégré, dans un monde plat jetable de graine 122 :
- Pose par la commande native d'utilisation ; sélection du pilier et visée
à travers le trou d'une arche jusqu'au bloc derrière, captures relues.
- Requêtes natives de collision côté serveur et client : voxels occupés solides,
espace vide libre, aucune profondeur artificielle d'un bloc entier.
- Pose refusée dans le joueur, sans consommer l'objet ; petite sculpture acceptée
dans la partie libre du même bloc, sans intersection avec le joueur.
- **80 cas** confrontent le volume et les faces de collision au maillage réellement
émis : arche creuse, marges vides, cube de 4 096 voxels, voxel unique, quatre
orientations et cinq ancrages. Chaque interrogation répétée réutilise la forme.
- Régression de **5 120 placements** du rendu existant : dimensions paires/impaires,
orientations et ancrages, plus marqueurs de direction et modèles avec marges.
- Changement d'orientation/ancrage puis changement des voxels sur la même entité :
cache recalculé et forme client synchronisée.
- **75 sculptures en trois couches** : skylight natif conservé à 15 partout.
Un toit de pierre témoin produit toujours une ombre. La lumière d'un bloc
traverse une sculpture avec l'atténuation de distance native (13 à deux blocs).
- Le rendu reçoit la lumière réelle du monde : 15 au ciel, **0 dans une pièce
fermée et obscure**. Aucun rendu artificiellement lumineux.
- Sauvegarde/reconnexion : modèle, collisions et lumière conservés sur les
deux côtés, sans fichier 3D source.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuarySculpture122ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Marqueurs `SCULPTURE122_PASS`, `SCULPTURE122_GEOMETRY_PASS`,
`SCULPTURE122_PLACEMENT_PASS`, `SCULPTURE122_LIGHT_PASS` et
`SCULPTURE113_GEOMETRY_PASS` dans `build/sculpture122-client.log` ; captures
conservées dans `build/sculpture122-evidence/`. Le test prépare explicitement
son sol à Y=0 : le preset plat nu commence à Y=-1.
Aucun monde personnel ouvert ; pas d'essai Windows ni depuis deux ordinateurs,
ni de garantie sur les ombres ajoutées par des shaders tiers.
Le GameTest serveur dédié reste exclu conformément au refus antérieur de son EULA.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 13 s**, 126 tâches. Les sources JAR correspondent aux sources
Java du dépôt. Par rapport à beta.121, les différences de production se limitent
aux classes des sculptures et à la nouvelle géométrie partagée (14 entrées,
y compris le déplacement du sélecteur synthétique d'orientation). Toutes les
ressources client, données serveur et configurations de mixins sont identiques.
Les packs normal/Test contiennent le même JAR, sans sauvegarde ni fixture.
Le template beta.122 contient les mêmes ressources que beta.121, octet pour octet.
Reçu local : `build/sculpture122-artifact.json`.
- `Sanctuary-beta.122.mrpack` : 10267459 octets, SHA-256
`b6e24d65797eb7f16670d2f3a591b5f2476820608f72241ecd37fb78a486008e`.
- `Sanctuary-Test-beta.122.mrpack` : 10286380 octets, SHA-256
`90487ee3ad4298fccb8c24e8781af4cbf043d88dc0379fba5c21d99733dac92d`.
JAR Sanctuary : SHA-256
`ff8ebf89f39a64809fae6912253386793e9b5e66ec0adb9fa7521729893d406e`.
## Publication et installation
La [release beta.122](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.122)
est publiée depuis `edc7fc3dfb117cde61be42c1d79b03af11f5dec6`. Tag et artefacts immuables,
téléchargements publics vérifiés. Canal packwiz :
`3d340bea0093b709c6018624795a1b3ca557b72d`.
Deux synchronisations isolées puis deux dans **Sanctuary Beta** réussissent.
Un seul JAR Sanctuary beta.122 est actif ; le second passage est identique.
Les **923 fichiers personnels et réglages suivis** restent inchangés.
Aucun monde personnel ouvert. Copie préalable dans
`sanctuary-backups/before-beta.122/` de l'instance existante ; reçus
`build/sculpture122-isolated.json` et `build/sculpture122-prism.json`.
Le ticket a été réalisé dans le worktree `sanctuary-sculpture-beta122` afin de
préserver les changements de `docs/vision.md` et `docs/villageois-illageois.md`
déjà présents sur la branche de documentation du checkout principal.
- [Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.122/Sanctuary-beta.122.mrpack).
- [Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.122/Sanctuary-Test-beta.122.mrpack).
- [Template personnel](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.122/Sanctuary-Template-beta.122.zip).
+110
View File
@@ -0,0 +1,110 @@
# beta.117 — Grain des textures et sculptures en chapeau
Ticket sur `codex/sculpture-texture-grain-beta117`, Minecraft 26.3.
En beta.116, une couleur du modèle source était associée à une seule nuance
de matière. Un modèle uni restait donc uni malgré les pixels différents de la
texture. La sélection utilise maintenant les textures des faces du modèle de
bloc, avec leurs propres teintes du pack actif. Un échantillonnage de pixels
répartit leurs couleurs exactes dans les voxels, sans couleur interpolée.
## Coût et comportement
Au maximum 32 matériaux de faces et 16 × 16 échantillons par matériau sont lus,
puis ramenés à la palette existante de 256 entrées. Les répétitions conservent
la fréquence des nuances. Les pixels transparents sont écartés ; les textures
animées utilisent la première image. Les blocs sans texture lisible gardent
le repli antérieur sur la texture représentative, puis la couleur de carte.
La sélection d'une nuance utilise un mélange entier déterministe des coordonnées
de chaque voxel. Un modèle uni utilise toute la palette ; un modèle ombré
utilise des plages de nuances qui suivent ses zones claires et sombres.
Ce grain est un transfert de palette, pas une projection UV conservant les
motifs ou les veinures du bloc. Les voxels restent opaques.
Le calcul utilise Java standard et les modèles/textures déjà chargés par
Minecraft, sans bibliothèque supplémentaire. Aucun calcul de matière dans
le rendu par image. Le client ne rééchantillonne qu'au changement de matière
ou de ressources ; une rotation réutilise la palette. Le serveur conserve
le modèle recoloré pour les fabrications suivantes avec la même sélection.
## Chapeaux et données
Le système de blocs portés retirait `BLOCK_ENTITY_DATA` des objets pour isoler
les inventaires des coffres et fourneaux. Sur une sculpture, ce composant est
la géométrie elle-même : le slot contenait encore un objet, devenu invisible.
Les sculptures conservent désormais ce composant à l'équipement et au retrait.
Si une ancienne sculpture équipée a perdu ce composant, mais que sa session
de chapeau possède encore le modèle, il est restauré depuis cette donnée
serveur. La restauration ne fabrique aucune géométrie manquante. Les objets
déjà retirés et dépourvus de toute copie du modèle ne sont pas reconstructibles
par ce correctif. Les règles de contenu des coffres restent inchangées.
Identifiants, schéma 1 des sculptures, structure des accessoires et paquet
de sélection inchangés. Les couleurs déjà sauvegardées restent intactes.
Les deux côtés utilisent beta.117 pour le même calcul de prévisualisation et
de fabrication. Aucun monde personnel ouvert ni migration de sauvegarde.
## Vérifications
Le scénario client natif complet passe en 2 min 1 s sur macOS avec serveur
intégré, monde plat jetable de graine 116. Il reprend le scénario beta.116
avec un GLB entièrement uni, plus les régressions de l'atelier 106/111/112/113 :
- 1 207 objets-blocs échantillonnés, huit fabrications payées avec aperçu
identique au résultat serveur, consommation exacte d'un bloc.
- Répartition 25 % / 75 % de deux couleurs de texture sur un modèle uni,
aucune couleur inventée, conservation des zones claires et sombres.
- Couleurs réellement présentes dans les textures natives des faces, avec
plusieurs matériaux d'une bûche ; grain présent sur pierre, bois et minerai.
- Motif indépendant de l'ordre des voxels, stable et sauvegardé ; rechargement
des ressources, palette bornée, inventaire plein et sélections périmées.
- Vrais clics des slots hotbar/chapeau, conservation exacte des composants
et du nom personnalisé, icône et modèle porté visibles, retrait/rééquipement.
- Restauration d'un objet privé de sa géométrie depuis une ancienne session
équipée, puis sauvegarde et reconnexion du client avec le chapeau intact.
- Pose, bord visé, orientation, casse/repose, reconnexion sans fichier source,
menus FR/EN et génération du plan du Métabli inchangés.
Mesure indicative après échauffement, modèle maximal de 4 096 voxels,
101 applications sur le Mac de développement : médiane **0,283 ms**, p95
**0,971 ms**. Ce n'est pas une garantie de temps sur d'autres machines ; le
calcul n'est exécuté ni chaque image ni à chaque objet fabriqué identique.
Marqueurs `SCULPTURE117_GRAIN_PASS`, `SCULPTURE117_HAT_PASS`,
`SCULPTURE117_HAT_RECONNECT_PASS`, `SCULPTURE116_PASS` dans
`build/sculpture117-final-client.log`. Captures relues dans
`build/sculpture117-evidence/`. Pas d'essai Windows ou LAN. Le GameTest dédié
reste exclu selon le refus antérieur de son EULA.
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussit
en **3 min 7 s**, 125 tâches dont 105 exécutées. JAR sources concordants,
versions, contenu et intégrité des deux archives vérifiés. La comparaison
avec beta.116 limite les changements de production au calcul des matières,
à son cache dans le menu et au traitement des sculptures en chapeau.
Les modèles de test et classes de GameTest sont absents de la livraison.
- `Sanctuary-beta.117.mrpack` : 10191137 octets, SHA-256
`adbe5a40bbd050ebc84ff8aca61a7a3643dcf25ec8f2ac59b91561e48275cbbb`.
- `Sanctuary-Test-beta.117.mrpack` : 10210059 octets, SHA-256
`0dacb12b30bde66c647871101a4f4c6b0b58e7ceb29f7743398f8a569b958a6e`.
- JAR Sanctuary :
`1ce63f97f17c6098f3c86c6753f9b912bce39d88e05ba240423c38bdc97307ee`.
Reçu : `build/sculpture117-artifact.json`.
## Publication et instance
La [release beta.117](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.117)
est publiée : JAR, MRpack normal/Test et amorçage Prism, téléchargements publics
et empreintes vérifiés. Le tag immuable désigne
`0f1efdec830342e25d3316b356330ad937d0d942`. Le canal packwiz avance au commit
`a5b75a70f86e42afb8e06ba4daa9880d61c6373e`.
Deux synchronisations isolées puis deux dans l'unique instance **Sanctuary
Beta** réussissent, la seconde passe laissant les fichiers gérés identiques.
Un seul JAR Sanctuary beta.117 est actif. Les 923 fichiers personnels et
réglages suivis sont inchangés ; aucun monde personnel n'a été ouvert.
Copie préalable des fichiers remplacés dans `sanctuary-backups/before-beta.117/`.
Reçus : `build/sculpture117-isolated.json`, `build/sculpture117-prism.json`.
+94
View File
@@ -0,0 +1,94 @@
# SNOW-109 — Fonte saisonnière
Branche `codex/seasonal-snow-melt-beta109`, base beta.108, Minecraft 26.3.
État : livraison locale vérifiée. Demande : ajouter la fonte saisonnière.
## Comportement livré
Neige déposée par la météo Sanctuary : retrait progressif d'une couche de haut
en bas, bloc plein puis sept couches, jusqu'au sol. Deux blocs maximum pour
l'accumulation restent la valeur par défaut confirmée. La fonte est active
avec la météo Real Time, suspendue en hiver et sous un profil neige/averses de
neige, même pendant ses pauses. Les biomes naturellement froids conservent
leur neige. Printemps : une chance sur deux par sondage météo natif ; été :
chaque sondage ; automne : une chance sur quatre. Aucun rattrapage hors ligne,
ni chargement forcé de chunks ; seuls les sommets exposés des chunks simulés.
À 20 ticks/s et `randomTickSpeed = 3`, une couche fond en moyenne en 3,4 minutes en été,
6,8 au printemps et 13,7 en automne. Pas d'eau ni de butin lors de la fonte.
Une règle serveur `sanctuary:seasonal_snow_melt`, active par défaut, permet
d'arrêter la fonte sans désactiver la neige.
## Contrat de données et migration, avant implémentation
Ajout d'une attache Fabric persistante par chunk,
`sanctuary:natural_snow_v1`, sans modifier les formats existants. Elle mémorise
les positions des nouveaux dépôts avec le nombre de couches antérieures et
le nombre attendu après dépôt (0 à 8). Les blocs restent les blocs Minecraft
`minecraft:snow` et `minecraft:snow_block` ; pas de nouvel identifiant de bloc.
L'absence de données signifie aucun dépôt suivi. Aucune recherche rétrospective,
conversion ou réécriture de terrain au chargement : la neige d'avant beta.109
est conservée car son origine n'est pas connue. Les sauvegardes personnelles
restent fermées pendant le développement. Graine de test : 109.
Seules les couches ajoutées par les précipitations depuis beta.109 fondent.
La neige construite, la neige générée et les couches antérieures à un dépôt
sont préservées. Une modification extérieure du bloc (casse, placement,
piston, commande) retire son suivi : le remplacement ne doit jamais hériter
d'une autorisation de fonte. Une incohérence de suivi est oubliée sans fonte.
Les informations suivent le cycle natif de sauvegarde/déchargement du chunk,
sans fichier global ni lecture disque par colonne. Le retrait du mod laisse
des blocs de neige natifs ; aucun effacement n'est lancé par migration.
## Vérifications ciblées
Le client natif lance les contrôles d'accumulation beta.108 (deux mondes neufs),
puis un monde neuf de graine 109 pour la fonte et le recharge après fermeture.
Les quatre saisons utilisent une horloge de test déterministe, sans commande
supplémentaire dans le mod livré. Les sondages appellent le véritable
`ServerLevel.tickPrecipitation`, sans accélération ajoutée au jeu distribué.
Cas vérifiés : bloc plein vers sept couches, fonte de haut en bas jusqu'au sol,
neige construite et couches anciennes, dépôt météo sur une construction,
casse/repose et ajout manuel de couches, toit et capuchon de neige manuel,
activation/désactivation par gamerule, les quatre saisons, profils neige et
pauses d'averses, biome froid et mode Vanilla, sauvegarde native des deux blocs
suivis et de la gamerule, puis fonte après rechargement.
Le choix du sommet utilise la heightmap native mise à jour immédiatement,
sans dépendre d'une propagation de lumière encore en attente après la fonte.
Le suivi est invalidé après toute modification effective de bloc ; chaque
nouveau dépôt ou retrait météo enregistre ensuite uniquement ses couches.
Les instantanés de suivi sont immuables pour la sauvegarde asynchrone des chunks.
## Livraison — 17 septembre 2026
- `./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest` :
réussi, 125 tâches, 2 min 20 s.
- `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true -PsanctuaryMelt109ClientTests=true -PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true` :
réussi, 54 s ; marqueurs `SNOW108_NATIVE_PASS` et `MELT109_NATIVE_PASS`.
- Java 25. Le GameTest sur serveur dédié reste exclu conformément au refus
antérieur de son EULA ; les essais natifs utilisent le serveur intégré.
- Archives ZIP, métadonnées, sources embarquées, mixins et traductions vérifiés
contre l'archive immuable beta.108. Deux classes existantes modifiées, trois
nouvelles classes plus la classe synthétique du switch de saison. Aucun
test inclus dans le JAR. Assets identiques, sauf deux nouveaux libellés FR/EN.
[Pack normal](../build/Sanctuary-beta.109.mrpack) ·
[Pack Test](../build/Sanctuary-Test-beta.109.mrpack).
| Artefact | SHA-256 |
| --- | --- |
| Normal | `4f80292b97398e993d05e831682c879d2e6b40e7ab8cb385a15142b68c2d3ba4` |
| Test | `c0c7363ec3a2a8c568b77692b65dd8a4138c247bc928adecd5626f83bb5097bb` |
| JAR Sanctuary | `0b53f5c2d9b50890067c42a1c71b84bf70f0c6af90c876020955bbe426ff6ffa` |
Reçu : `build/melt109-artifact.json`. Journaux : `build/melt109-native.log`,
`build/melt109-check-build.log`. Manifeste : `build/melt109-source-manifest.json`.
Assemblage isolé sur beta.108, avec accumulation et œufs beta.107 ; Statuaire
beta.106 reste livré séparément. Sources intégrées au répertoire principal en
préservant ses autres travaux et ses compteurs beta.106 ; le worktree de
livraison utilise beta.109 dans les deux compteurs et le manifeste packwiz.
Aucun tag, publication du canal, déploiement, installation Prism ou monde
personnel modifié. Aucun essai Windows. La fonte n'est pas appliquée
rétrospectivement à la neige ancienne dont l'origine est inconnue.
+88
View File
@@ -0,0 +1,88 @@
# SHADER-127 — intégration des ombres portées pixélisées
Demande du 17 septembre 2026 : livrer le shader dans **beta.127**.
Branche `codex/shader-beta127`, socle publié beta.126, Minecraft 26.3 / Java 25.
La préparation locale [des ombres pixélisées](pixel-shadows-source122.md) est
intégrée sans écraser les changements non publiés du dossier d'origine.
Les gemmes, dernières textures de rubis/saphir, l'argile spéciale et les
commandes de découverte de beta.126 sont conservées.
## Utilisation
**Options → Shaders → Sanctuary · Vanilla Light → Ombres portées pixélisées**.
L'option est désactivée par défaut ; son activation reste un choix du joueur.
Finesse : 8/16/32 pixels par bloc ; portée : 16/32/64 blocs. Défauts : 16 et 32.
Les paramètres FR/EN s'appliquent immédiatement et persistent dans
`config/sanctuary-shaders.json`. La mise à jour préserve les réglages personnels.
Le rendu est natif au mod : aucun pack Iris, téléchargement supplémentaire ou
nouvelle dépendance de rendu.
## Périmètre
Ombres solaires des formes d'occultation des blocs chargés, incluant dalles,
escaliers et obstacles hors écran. Carte de profondeur GPU, grille ancrée dans
le monde, atténuation selon soleil, pluie et brume. L'effet s'arrête la nuit,
dans les dimensions sans soleil, dans les fluides ou avec un moteur externe.
Le HUD et la main ne passent pas dans cet effet. La lumière de gameplay,
les règles serveur, les sauvegardes et la génération ne sont pas modifiées.
La préparation limite le cache à 96 Mio et sa construction à quatre sections
par image dans un budget souple de 3 ms. L'intégration supprime seulement une
invalidation de bord exécutée deux fois à l'identique dans le cache de terrain.
Les formes non occultantes, feuillages ajourés, eau/verre et entités animées ne
projettent pas de nouvelles silhouettes. Les transparences déjà composées
peuvent recevoir l'assombrissement du terrain situé derrière elles. Pas
d'ombres de torches/lune ni de mesure de FPS revendiquée.
## Vérifications et livraison
Client natif Minecraft 26.3 / Java 25 sur macOS, monde neuf plat, graine 122 :
`PixelShadows122ClientChecks` réussit sur la beta.127 en **1 min 26 s**.
Le nom de cette fixture historique est conservé pour suivre la même scène.
- Activation : 920 échantillons de sol assombris, 63 412 inchangés, aucun éclairci.
- Pilier hors écran : 42 048 échantillons restent ombrés ; le retrait du pilier
restitue la lumière sur les 42 048 et conserve les 23 616 déjà éclairés.
La repose rétablit la même ombre sans désactiver le cache.
- Trois finesses et trois portées, redimensionnement GPU, désactivation/libération,
arrêt nocturne, reprise diurne et rechargement des ressources vérifiés.
- Préférences persistantes et bornées ; captures FR/EN inspectées, sans texte
tronqué. La scène pilier/dalle/escalier est également inspectée visuellement.
Logs : `build/shader127-client.log`. Captures :
`mods/sanctuary/build/run/clientGameTest/screenshots/`.
Le serveur GameTest dédié reste exclu conformément au refus antérieur de son
EULA. Aucun monde personnel n'est ouvert. Aucun essai Windows/Vulkan ou FPS
n'est revendiqué ; la validation graphique est celle du client natif local.
Build `./gradlew check build assemblePack assembleTestPack
-x :sanctuary:runGameTest` réussi en **2 min 25 s**, 126 tâches. Les sources
Java dans les archives correspondent aux sources du ticket. La comparaison
avec le JAR beta.126 ne trouve que **21 entrées de production modifiées**,
toutes liées au shader, à son initialisation, ses mixins et ses libellés.
Les règles de jeu, données, textures de gemmes, sculptures et découvertes
sont identiques. Normal et Test contiennent le même JAR ; le template exporte
exactement les ressources du mod. Aucune fixture de test ni monde embarqué.
| Artefact | SHA-256 |
| --- | --- |
| `Sanctuary-beta.127.mrpack` | `23fa6ca0c5d1f1033e13e5370d669491b398d5a99e13f8bd1e983f998a24a20a` |
| `Sanctuary-Test-beta.127.mrpack` | `f185f3a743ba23cd6c024d1c1e46587706bf4f3edb5e14d7e5dee522a1ebf2c2` |
| `sanctuary-beta.127.jar` | `3ef3fab67f4f703864a1acc9ae2395c636d206f3128d3399140eaf045a46d5f3` |
## Publication et synchronisation
[Release beta.127](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.127)
publiée depuis `00be1eb0d0bfe042442d90255385033a7765092f` ; artefacts immuables retéléchargés
et vérifiés. Canal packwiz : `7fd24a43e01ee497a23311e3c78e1dd3dddd877a`.
Deux synchronisations isolées puis deux dans l'instance existante
**Sanctuary Beta** ont réussi. Les **923 fichiers personnels** suivis,
y compris les préférences de shaders, sont conservés. Aucun monde personnel
ouvert ; copie préalable dans `sanctuary-backups/before-beta.127/`.
Les packs normal/Test et le template sont aussi copiés et vérifiés dans
`sanctuary-beta/build/`. Reçus locaux : `build/shader127-artifact.json`,
`build/shader127-isolated.json`, `build/shader127-prism.json` et journal de
publication `build/shader127-publication.log`.
+106
View File
@@ -0,0 +1,106 @@
# SHADER-128 — stabilité, lune et feuillage
Demandes du 17 septembre 2026 : réduire le jitter des bords et à midi, conserver
des ombres de blocs nettes et un feuillage nuancé, ajouter une ombre lunaire
fine et une transmission
irrégulière du feuillage selon son épaisseur. Branche `codex/moon-foliage-beta128`,
socle publié beta.127, Minecraft 26.3 / Java 25.
## Résultat
Les faces de voxels reçoivent l'ombre sur une grille fixe ; leur plan est
stabilisé au 1/16 de bloc et les faces inclinées conservent leur normale. La
carte de profondeur utilise un ancrage par 16 blocs avec alignement de ses
texels. **Les ombres des blocs restent nettes** : le créateur a écarté leur
adoucissement après l'essai visuel. Le filtrage est réservé au feuillage.
Le soleil continue de se déplacer : ce mouvement naturel peut toujours faire
avancer une ombre d'une case. L'objectif est de supprimer les oscillations
liées à la caméra, pas de figer le temps.
Entre une élévation solaire de 0,92 et le zénith, l'intensité diminue doucement
jusqu'à 25 % de sa valeur habituelle : alpha maximal 0,095 au lieu de 0,38.
La lune réutilise la même carte et son angle natif ; alpha maximal 0,10 selon
sa phase, nul à la nouvelle lune. Horizon, pluie, portée et brume atténuent
les deux lumières. Pas de seconde passe de carte lunaire simultanée.
Les blocs du tag vanilla `leaves` projettent une couverture probabiliste fixe
sur une grille de quart de bloc. Les faces internes restent présentes : plus
le rayon traverse de couches, moins il a de chances de passer. Il s'agit d'une
approximation statistique de l'épaisseur, pas d'un comptage des feuilles ni
d'une lecture des trous de leur texture. Quatre échantillons bilinéaires sur
un quart de bloc mélangent cette couverture
en nuances, évaluées sur la grille pixélisée. Un masque R8 distingue feuilles
et blocs dans la même passe de profondeur, pour garder les silhouettes opaques
nettes. Les comparaisons suivent le plan récepteur pour éviter l'auto-ombrage
sur les surfaces obliques. Le motif ne dépend ni du temps ni de la caméra et
ne demande aucune lecture GPU côté CPU en production.
Les budgets du cache restent 96 Mio, quatre sections et 3 ms souples par image.
Le feuillage ajoute des faces ; son filtrage utilise seize comparaisons de
profondeur et, selon le cas, autant de lectures du masque. Les ombres opaques
pleines utilisent le chemin court. Le masque prend 4 Mio à la définition
par défaut, 16 Mio au maximum ; aucune promesse de FPS n'est faite.
Réglages existants et libellés FR/EN conservés/actualisés. Les ombres restent
optionnelles et désactivées par défaut. Règles, éclairage de gameplay, mondes
et génération inchangés. Les limitations de beta.127 concernant les entités,
fluides, verre et autres formes non occultantes subsistent.
## Vérifications
`PixelShadows122ClientChecks`, fixture historique étendue, réussit en **1 min
44 s** dans un monde neuf plat, graine 122, client natif macOS/OpenGL.
- Soleil : 920 échantillons de sol ombrés et 63 412 inchangés ; retrait/repose
d'un pilier hors écran restitue puis rétablit son ombre.
- Lune : ombre localisée mesurée ; arrêt des passes à la nouvelle lune.
- Midi : ombre atténuée et **zéro échantillon de transition adoucie sur 65 664**
dans la scène de contrôle du contour opaque.
- Caméra : petits déplacements autour de l'ancien ancrage entier et du nouvel
ancrage par 16 blocs, retour au point initial, petites rotations en vue
oblique, instants avant/après midi et passage de 5999 à 6001 ticks.
Comparaisons d'images avec tolérance de trois niveaux par canal et moins
de 1 % d'échantillons changés pour ces petits mouvements.
- Vue oblique : 19 637 échantillons ombrés, 46 027 inchangés sur le sol blanc.
- Feuillage : assombrissement moyen mesuré d'environ **18,6 %** pour une couche,
**31,7 %** pour quatre, contre **36,8 %** sous une couverture solide.
Motif fixe dans le temps ; modification des couches invalide le cache actif.
- Trois finesses/trois portées, désactivation/libération GPU, préférences
persistantes, rechargement des ressources, textes FR/EN et captures inspectés.
Commande native : `./gradlew :sanctuary:runClientGameTest
-PsanctuaryClientTests=true -PsanctuaryPixelShadows122ClientTests=true
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true` sous Java 25.
Logs : `build/shader128-client.log` ; captures sous
`mods/sanctuary/build/run/clientGameTest/screenshots/`.
`./gradlew check build assemblePack assembleTestPack
-x :sanctuary:runGameTest` réussit en **2 min 24 s**, 126 tâches.
Les sources archivées correspondent au ticket. **13 entrées de production**
diffèrent de beta.127, limitées aux classes du shader, GLSL et libellés FR/EN.
Gemmes, sculptures, découvertes, données et règles de jeu restent identiques.
Normal et Test embarquent le même JAR ; le template correspond exactement aux
ressources exportables. Aucun monde ni fixture de test embarqué.
Le serveur GameTest dédié reste exclu conformément au refus antérieur de son
EULA. Aucun monde personnel utilisé. Aucun essai Windows/Vulkan revendiqué.
## Artefacts vérifiés
| Artefact | SHA-256 |
| --- | --- |
| `Sanctuary-beta.128.mrpack` | `84125b18366afde52c8cba4bc7007257b1c3d8b79d413338abe64d0e0ae8feaf` |
| `Sanctuary-Test-beta.128.mrpack` | `6bfb10d8d63875bfb84e813d4f0a9faf5db30902cc0400deda0993c08b86e597` |
| `sanctuary-beta.128.jar` | `c54cbae7efaa7059c8fd4d28801087983379958fe34eedff51682d106d649c6a` |
## Publication et synchronisation
[Release beta.128](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.128)
publiée depuis `a4e96c0d99c976231d7ca92b1db6bdb6aa7083c6` ; les artefacts immuables ont été
retéléchargés et vérifiés. Canal packwiz : `eba01726ee132a6e548d129fa7ee04f24c880917`.
Deux synchronisations isolées puis deux dans l'instance existante
**Sanctuary Beta** ont réussi. Les **923 fichiers personnels** suivis,
y compris les réglages de shaders, sont conservés. Aucun monde personnel
ouvert ; copie préalable dans `sanctuary-backups/before-beta.128/`.
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
Reçus locaux : `build/shader128-artifact.json`, `build/shader128-isolated.json`,
`build/shader128-prism.json` ; journal : `build/shader128-publication.log`.
+119
View File
@@ -0,0 +1,119 @@
# SHADER-129 — arêtes connectées et portées étendues
Demandes du 17 septembre 2026 : highlight optionnel, puis raccordement CTM de
tous les blocs coplanaires, portée indépendante 16256 blocs, ombres à 128/256,
suppression de l'atténuation au zénith et du réalignement cyclique des ombres.
Branche `codex/edge-highlights-beta129`, socle beta.128, Minecraft 26.3 / Java 25.
## Résultat
Dans **Options → Shaders → Sanctuary · Vanilla Light**, les reflets sur les
arêtes sont désactivés par défaut. L'intensité va de 0 à 100 ; la valeur 30
éclaircit au maximum de 12 %. Leur portée se règle indépendamment des ombres :
**16, 32, 64, 128 ou 256 blocs**. Les anciens réglages restent conservés.
Le highlight raccorde les surfaces plates, y compris entre matériaux différents,
sans dessiner les jonctions intérieures. Il suit les contours visibles du relief,
y compris les dalles et escaliers. Il multiplie la couleur existante pour garder
les pixels des textures et composer avec les ombres. Ce CTM géométrique utilise
la profondeur déjà produite par Minecraft : aucune liste de blocs autorisés,
aucun pack CTM à fournir, aucune exploration CPU du volume de 256 blocs.
Une cassure/repose modifie le contour dès que le terrain natif est réaffiché.
Le trait proche suit la grille de 1/16 de bloc. À distance, la couverture devient
partielle pour limiter le scintillement des traits plus petits qu'un pixel.
La portée choisie s'efface progressivement dans son dernier quart et respecte
la brume ainsi que la distance de rendu. Une passe plein écran, quatre sondes
de profondeur et un tampon uniforme de 176 octets ; aucun retour GPU vers CPU
en production. L'arrêt de l'option ou une intensité nulle libèrent ce tampon.
Ce n'est pas un remplacement des textures par des variantes CTM. Le calcul
concerne les faces visibles alignées sur la grille : les faces hors écran,
les surfaces transparentes absentes de la profondeur et les géométries
arbitrairement inclinées ne sont pas reconstruites. À très grande distance,
les détails plus petits qu'un pixel peuvent rester imperceptibles. Aucun
chargement de chunk forcé, aucune promesse de FPS.
Les ombres proposent les cinq mêmes portées. La carte reste plafonnée à
4096 × 4096 : les grandes distances réduisent sa précision spatiale. Le cache
garde ses limites de 96 Mio, quatre sections et 3 ms souples de construction
par image ; la réconciliation des chunks est limitée aux ticks et changements
de caméra/terrain plutôt qu'à toutes les images.
L'intensité solaire reste pleine au zénith. Les contours opaques restent nets,
le filtrage du feuillage et les ombres légères de la lune sont conservés.
Le réalignement de la grille lumineuse a lieu au changement d'ancrage de la
caméra, avec transfert de phase par texels entiers. La rotation du soleil ne
réarrondit plus la projection de l'origine mondiale à chaque image, source
de sauts périodiques amplifiés aux grandes coordonnées. Le soleil continue
d'avancer ; une ombre pixélisée peut naturellement changer de case. Cette
correction ne modifie pas l'horloge serveur ni sa cadence native.
Textes FR/EN. Aucun changement de règles, sauvegardes, génération, gemmes,
sculptures ou découvertes.
## Vérifications et livraison
Le client natif réussit en **2 min 41 s**, dans un monde neuf plat, graine 122,
sur macOS / Apple M1 / OpenGL :
- CTM : 596 échantillons éclaircis sur le contour et 65 068 conservés ; les
20 centres et 20 jonctions coplanaires ciblés restent intacts, y compris
au changement de matériau. L'intensité 90 produit environ trois fois le gain.
- Ouverture d'un trou : 716 échantillons éclaircis ; la fermeture restitue
l'image connectée. Au-delà de 16 blocs, le réglage 16 supprime le contour ;
le réglage 64 le rétablit (254 échantillons éclaircis).
- Cinq portées CTM et cinq portées d'ombres, persistance/clamp, migration des
anciennes préférences, arrêt/libération, rechargement, caméra et vue oblique
avec ombres. Captures françaises/anglaises inspectées ; suppression ensuite
d'un préfixe redondant dans le libellé de portée.
- Soleil, lune, nouvelle lune, ombres opaques nettes à midi et transmission
du feuillage restent vérifiés. Casters hors écran et retrait/repose actifs.
- 2 000 petits pas solaires aux coordonnées ±100 000 vérifient l'absence de
saut de translation de la carte ; changement d'ancrage par texels entiers.
La séquence du vrai service realtime suit 294 images : angle avancé de
0,000694 radian, aucun rebond dans les 65 664 échantillons de sol suivis.
Sur cette courte séquence, aucune case opaque n'a changé ; cela ne constitue
pas une mesure de mouvement visible sur une journée entière.
La fixture initiale capturait parfois le terrain avant la fin de sa construction
native : elle attend désormais la réception de toute la plateforme et la fin
compilée des sections. Aucun changement de gameplay pour contourner ce problème.
Commande : `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuaryPixelShadows122ClientTests=true -PsanctuaryClientNoVsync=true
-PsanctuaryQuickTests=true`. Log `build/shader129-client.log`, captures sous
`mods/sanctuary/build/run/clientGameTest/screenshots/`.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 17 s**, 126 tâches. Une dernière passe dassemblage intègre
la simplification du libellé de portée (2 min 18 s). Les archives sont vérifiées :
**17 entrées de production** diffèrent de beta.128, limitées au shader et à ses
interfaces. Même JAR dans normal/Test, sources archivées conformes, ressources
du template exactes, assets des gemmes inchangés. Aucun monde ni test embarqué.
Le serveur GameTest dédié
reste exclu conformément au refus antérieur de son EULA. Aucun monde personnel
utilisé pour les essais, aucun résultat Windows/Vulkan revendiqué.
Archives vérifiées dans `build/` et recopiées dans le dossier de livraison
habituel `sanctuary-beta/build/` :
- Normal : 10 353 946 octets, SHA-256
`2c7735194faab8a699004e69ad543ce6e8fabdebe297b759bffd31fea2dcc2e8`.
- Test : 10 372 872 octets, SHA-256
`cca572ee313d1d7e66ed745bb48bac62490998760d9b7bbfc7a1f6d60c430dce`.
- JAR Sanctuary :
`ca3e2ff4e0717a3a54c1d57478d43aade39d001b71c4eec745d76f010f7def54`.
[Release beta.129](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.129)
publiée depuis `35103a7ea8635dc4ed26293f00e86d99aaacfca8`. Canal packwiz
`2eaa0130a4cd475c598beefdab40f48fc8681ab7`. Deux synchronisations isolées puis deux synchronisations
dans l'instance **Sanctuary Beta** ont réussi. Les **923 fichiers personnels**
suivis, dont les réglages de shader, sont inchangés. Aucun monde personnel ouvert.
Sauvegarde préalable des fichiers gérés : `sanctuary-backups/before-beta.129/`.
Le JAR installé a le hash vérifié ci-dessus. Reçus locaux :
`build/shader129-artifact.json`, `build/shader129-isolated.json` et
`build/shader129-prism.json`. Le checkout initial avec ses modifications en
cours a été préservé ; seule la copie des archives versionnées rejoint son
répertoire `build/`.
+5
View File
@@ -102,3 +102,8 @@ client natif : témoin `SHADER040_CLIENT_PASS` dans
`build/operator040-client-final.log`. Captures dans
`build/operator040-evidence/final/`, reçu dans `build/operator040-artifact.json`.
Les contrôles ne revendiquent pas un essai avec Iris ou un autre moteur externe.
Les ombres portées pixélisées sont livrées dans [beta.127](shader-beta127.md),
intégrées sur la beta.126. Leur préparation locale portait un numéro beta.122
non publié ; la release beta.122 existante n'est pas remplacée.
+115
View File
@@ -0,0 +1,115 @@
# SNOW-108 — Accumulation de neige en volume
Branche `codex/snow-accumulation-beta108`. Base livrée beta.107, Minecraft 26.3.
État : livraison locale vérifiée. Statuaire beta.106 reste une livraison séparée.
## Contrat avant modification
Demande : la neige doit saccumuler et occuper réellement des blocs.
Les précipitations déposent une couche à la fois ; la huitième devient un bloc
natif de neige solide. Le dépôt suivant commence au-dessus. Même cadence de
précipitation que Minecraft, uniquement dans les chunks simulés pendant une
chute de neige, y compris les averses Sanctuary et les biomes froids vanilla
lorsque les règles Sanctuary sont actives.
Valeur confirmée par le créateur : deux blocs de hauteur totale par défaut,
réglables côté serveur.
Le plafond compte la colonne contiguë de neige déjà présente (couches et blocs),
jusquau premier support dune autre matière. Zéro désactive laccumulation,
moins un permet de continuer jusqu’à la limite de hauteur du monde.
La lumière, les supports, la place libre et les biomes acceptables restent
contrôlés par Minecraft. Ni plantes, ni équipements, ni eau, ni constructions
ne sont remplacés. La croissance pousse les entités vers le haut comme le dépôt
natif, avec la nouvelle collision. Les chaudrons gardent leur fonctionnement.
## Données et compatibilité
Uniquement des blocs natifs `minecraft:snow` et `minecraft:snow_block` : collision,
minage, butin et sauvegarde natifs. Pas de bloc, BlockEntity ou format propriétaire.
La nouvelle gamerule `sanctuary:snow_accumulation_height` utilise la sauvegarde
native des règles, sans réécriture des règles préexistantes. Elle sajoute avec
sa valeur par défaut quand absente. Aucune sauvegarde personnelle ouverte, aucun
balayage de chunks, aucune expansion ni conversion de terrain au chargement.
Seuls les dépôts futurs pendant le jeu, autorisés par la demande, changent.
Les blocs de neige pleins suivent le comportement Minecraft : ils ne fondent pas
comme les fines couches éclairées. Cette livraison najoute pas une fonte
saisonnière qui pourrait effacer des constructions en neige.
La valeur native `minecraft:max_snow_accumulation_height = 0` reste un arrêt
prioritaire. Dans un monde Sanctuary, toute valeur native positive autorise le
dépôt volumétrique, dont le plafond est la nouvelle règle en blocs. Hors Sanctuary,
le fonctionnement et la limite vanilla sont conservés.
## Utilisation
- Valeur par défaut : `/gamerule sanctuary:snow_accumulation_height 2`.
- Quatre blocs : `/gamerule sanctuary:snow_accumulation_height 4`.
- Arrêt des nouveaux dépôts : valeur `0`.
- Sans plafond particulier, sauf celui du monde : valeur `-1`.
- Valeurs admises : de `-1` à `128`, positives exprimées en blocs complets.
Réduire le plafond ne supprime pas la neige déjà présente. La limite native
`minecraft:max_snow_accumulation_height` nest pas réécrite automatiquement.
Les noms et descriptions de la nouvelle règle sont traduits en FR/EN dans
linterface native de création du monde.
## Essais natifs
Nouveaux mondes plats de développement, graine `108`, un avec Sanctuary et
un sans. Le test appelle le véritable `ServerLevel.tickPrecipitation` :
- Sept couches successives, huitième dépôt en `snow_block`, collision pleine.
- Un cochon debout sur la neige monte avec le nouveau volume.
- Empilement sur le bloc formé ; arrêt à deux blocs ; commande à quatre blocs,
puis mode sans plafond et six blocs atteints.
- Arrêt à zéro sur la règle Sanctuary et priorité du zéro sur la règle native.
- Dépôt pendant les averses, aucun dépôt pendant leurs pauses ni sous pluie tiède.
- Fleur et eau libre préservées, neige sur le toit et non dans la pièce ;
lumière propagée par un bloc lumineux empêchant le dépôt.
- Le chaudron continue de recevoir sa neige poudreuse native.
- Météo vanilla, biome froid et Sanctuary actif : accumulation volumétrique.
- Second monde sans Sanctuary : une couche native même après 40 tentatives.
La décision dactivation est mise en cache au démarrage du serveur, daprès le
choix Sanctuary fixé à la création. Aucun accès disque par colonne de pluie.
Le comptage de profondeur sarrête dès le plafond atteint ; le mode illimité
na pas besoin de compter. Pas de boucle ajoutée sur les chunks ou les joueurs.
Les tests ne changent aucune sauvegarde personnelle. Les dépôts sont déclenchés
explicitement pour valider leur résultat ; aucun facteur daccélération météo
nest ajouté au code livré.
## Livraison et vérifications — 17 septembre 2026
Validation dans le worktree `sanctuary-snow-beta108`, sur la base immuable
beta.107. Les œufs de beta.107 sont inclus ; les sources Statuaire beta.106
présentes dans le répertoire principal restent préservées, mais ne sont pas
incluses dans ces archives. Les compteurs de ce worktree sont synchronisés à
beta.108 ; ceux du répertoire principal restent attachés à son chantier beta.106.
- `./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest` :
réussi, 125 tâches, 3 min 34 s.
- `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true -PsanctuarySnow108ClientTests=true -PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true` :
réussi, 1 min 17 s, marqueur `SNOW108_NATIVE_PASS`.
- Java 25 ; GameTest dédié exclu conformément au refus antérieur d'accepter son
EULA. Les essais ci-dessus utilisent le serveur intégré dans deux mondes neufs.
- Intégrité ZIP, versions Minecraft/mod, JAR embarqués, sources, mixins et libellés
vérifiés. Par rapport au JAR beta.107 : seulement `WeatherService.class`
modifié et les deux classes d'accumulation ajoutées ; assets inchangés sauf
les deux nouveaux libellés de règle FR/EN. Aucun test embarqué dans le JAR livré.
Archives locales : [pack normal](../build/Sanctuary-beta.108.mrpack) et
[pack Test](../build/Sanctuary-Test-beta.108.mrpack).
| Artefact | SHA-256 |
| --- | --- |
| Normal | `18f2b0c6a12c3618a9b70e22999b89697a41034e86bc1aa33f0c9d19caadd0c2` |
| Test | `66f9395cd47b031ca52162081c397da7a5ab7fdfa3e236dec5110dbcf95306bc` |
| JAR Sanctuary | `dc2b026cd0b6475e512283f72225593666fd60a3a265b8e91bf6bdae9f875bb4` |
Reçu : `build/snow108-artifact.json`. Journaux : `build/snow108-native.log`,
`build/snow108-check-build.log`. Manifeste : `build/snow108-source-manifest.json`.
Aucun tag, publication, canal packwiz, installation Prism ou monde personnel
modifié. Pas d'essai Windows ; pas de fonte saisonnière des blocs pleins.
+107
View File
@@ -0,0 +1,107 @@
# EGGS-107 — Naissances en œufs et récupération des générateurs
Branche `codex/eggs-beta107`. Travail isolé du chantier Statuaire beta.106.
Base vérifiée : sources de la livraison beta.105. État : livré localement, vérifié ; aucun déploiement ni publication.
## Contrat
Dans les mondes où Sanctuary est actif, une reproduction réussie remet un
œuf natif de l'espèce au sol au lieu d'ajouter directement le nouveau mob.
L'enfant est calculé par Minecraft : croisements, variantes, propriétaire et
caractéristiques héritées sont conservés. Les délais, nourriture, statistiques
et progrès restent ceux de la reproduction native. L'œuf d'un animal qui a un
stade bébé fait naître un bébé, y compris avec un distributeur.
Grenouille : œuf de têtard, pour conserver son cycle naturel. Tortue et sniffer :
œuf d'apparition de bébé à la place de la ponte. Allay : duplication contre
améthyste et musique, avec le délai natif conservé. Villageois : nourriture et
lit libre restent requis ; le lit réservé est libéré tant que l'œuf est stocké.
Un générateur classique réellement cassé par un joueur en survie, avec un outil
adapté sans Toucher de soie, remet un seul œuf correspondant à son prochain mob
configuré. Fortune ne multiplie pas ce résultat. Ni explosion, ni annulation de
casse, ni mode créatif ne produisent cet œuf. Les générateurs d'épreuves ne sont
pas transformés en source d'œufs par cette règle.
Les identifiants natifs des items restent inchangés. Les données de naissance
utilisent les composants d'item natifs ENTITY_DATA et CUSTOM_DATA ; aucune
migration de sauvegarde, régénération ou ouverture de monde personnel.
Les naissances et pontes déjà présentes restent en place. Aucune modification
hors des mondes Sanctuary ; un œuf déjà obtenu garde ses données de naissance.
Un catalogue distinct des 88 espèces sépare les voies livrées des propositions
à valider. Les méthodes spéciales restantes ne sont pas annoncées comme livrées.
## Catalogue
[Les 88 espèces et leurs voies](spawn-eggs-catalogue-beta107.md) : 28 par
reproduction ou duplication, 7 par les générateurs classiques vanilla et
53 propositions. Réponse du créateur : **catalogue à valider dabord**.
Aucune des 53 propositions nest implémentée dans ce ticket.
## Vérifications natives
Nouveau monde de test plat, graine `107`, Minecraft 26.3 / Fabric 0.160.5+26.3,
Java 25, macOS. Le test utilise le vrai client et son serveur intégré :
- Naissance native de 22 espèces via `Animal.spawnChildFromBreeding` : un seul
œuf, aucun enfant vivant ajouté, âge de bébé stocké, délai des deux parents.
- Cheval × âne → mule ; moutons rouge × bleu → bébé violet, à la pose et au clic
sur un adulte ; loup apprivoisé → bébé encore lié au même propriétaire.
- Sérialisation/relecture de litem avec le codec natif ; position, UUID et
réservations de cerveau absents ; éclosion en survie consomme un œuf.
- Vrai comportement de distributeur : bébé et couleur héritée.
- Grenouille sans seconde ponte, tortue sans état de ponte, renifleur sans œuf
bloc supplémentaire ; allay avec délai conservé ; villageois avec résultat
vide pour que lappelant rende le lit réservé, bébé à l’éclosion.
- Existence native des 88 œufs ; destruction réelle dun générateur par le
contrôleur joueur avec pioche normale/Fortune/Toucher de soie, main vide,
créatif et règle de drops de blocs désactivée.
- Deuxième monde neuf, Sanctuary désactivé à la création : bébé de vache vivant,
aucun œuf et générateurs sans nouveau butin.
Les appels natifs de naissance sont testés directement ; il ne sagit pas dun
élevage de chaque espèce laissé tourner pendant plusieurs minutes. Les tests
neffectuent pas de parcours complet de raid, donjon ou guérison : ces voies
spéciales sont des propositions. Aucun monde personnel modifié.
## Binaire et distribution
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit : **125 tâches, 3 min 44 s**. Le serveur GameTest dédié reste exclu,
lacceptation de son EULA n’étant pas autorisée. Les tests natifs ci-dessus
utilisent le serveur intégré ; dernier passage **29 s**, marqueur
`EGGS107_NATIVE_PASS`, après les derniers changements de code.
Commande des essais :
```sh
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryEggs107ClientTests=true -PsanctuaryClientNoVsync=true \
-PsanctuaryQuickTests=true
```
Lassemblage et les essais se font dans le worktree voisin
`sanctuary-eggs-beta107`, branche `codex/eggs-beta107`, sur les sources vérifiées
de beta.105. Les changements propres aux œufs sont aussi intégrés au répertoire
principal sans réécrire le code ni les compteurs en cours du chantier beta.106.
**Ces archives ne contiennent pas le Statuaire beta.106 en cours de validation.**
Les compteurs du worktree et du pack livré sont tous `beta.107`.
Comparaison binaire avec beta.105 : neuf classes ajoutées, aucune ancienne classe
modifiée. Les seules ressources graphiques/données différentes sont les deux
fichiers de langue FR/EN, avec deux nouveaux libellés ; le pack de textures,
les recettes et la génération restent byte pour byte identiques. Les mixins
ajoutés figurent dans le manifeste. Sources/JAR, ZIP, versions, dépendances,
88 lignes uniques du catalogue et empreintes sont vérifiés.
- [Pack normal](../build/Sanctuary-beta.107.mrpack) — SHA-256
`1b92c5abcc07be9c36d507d2ffdaf6d420a541c6624f105f8643594a02884600`.
- [Pack de test](../build/Sanctuary-Test-beta.107.mrpack) — SHA-256
`db9e32591cb263667b397fdb78738fbe45f58689b5574f2cb483f076635e7eef`.
Logs et reçus ignorés : `build/eggs107-check-build.log`,
`build/eggs107-native.log`, `build/eggs107-artifact.json`,
`build/eggs107-source-manifest.json`. Les fichiers sources des tests sont inclus
dans le dépôt ; aucune classe de test nest embarquée dans le mod livré.
Aucun test Windows, installation Prism, publication du canal, commit ou tag.
+155
View File
@@ -0,0 +1,155 @@
# Acquisition des 88 œufs — catalogue à valider
Demande du créateur : **valider les voies spéciales avant de les implémenter**.
Ce catalogue utilise les 88 identifiants de `CompanionType`, pour Minecraft 26.3.
Les œufs natifs servent aussi aux familiers. Aucun nouvel œuf propriétaire.
**28 espèces** ont une voie par reproduction/duplication dans EGGS-107,
**7** par les générateurs classiques des structures vanilla,
et **53** attendent une voie spéciale.
Les structures doivent être présentes et accessibles dans la partie : cette
livraison najoute aucun donjon, ne régénère aucun chunk et ne garantit pas ces
structures dans un monde plat. Un générateur classique configuré autrement
remet aussi l’œuf de son type, sil existe ; cela ne crée pas une voie naturelle
pour toutes les espèces. Les générateurs d’épreuves ont une proposition distincte.
## Voies intégrées à EGGS-107
Une reproduction donne **un œuf**, jamais simultanément un bébé. Les règles
natives daccouplement, dapprivoisement, de nourriture et de délai restent
applicables. Les poissons crus ne remplacent pas un seau quand le jeu exige un
poisson vivant. Les œufs de bébé restent bébés après stockage ; le temps de
croissance commence à l’éclosion.
| Espèce (identifiant natif) | Obtention |
| --- | --- |
| `cat` | Deux chats apprivoisés, morue ou saumon crus. |
| `chicken` | Deux poules, graines. |
| `cow` | Deux vaches, blé. |
| `donkey` | Deux ânes apprivoisés, carottes ou pommes dorées. |
| `fox` | Deux renards, baies sucrées ou lumineuses ; confiance héritée. |
| `horse` | Deux chevaux apprivoisés, carottes ou pommes dorées ; caractéristiques héritées. |
| `llama` | Deux lamas apprivoisés, bottes de paille ; force et variante héritées. |
| `mule` | Croisement cheval × âne. La mule reste stérile. |
| `pig` | Deux cochons, carotte, pomme de terre ou betterave. |
| `rabbit` | Deux lapins, carotte, carotte dorée ou pissenlit. |
| `sheep` | Deux moutons, blé ; mélange de couleurs natif. |
| `tadpole` | Deux grenouilles, boules de slime ; œuf de têtard à la place de la ponte aquatique. |
| `armadillo` | Deux tatous, yeux daraignée. |
| `bee` | Deux abeilles, fleurs. |
| `camel` | Deux dromadaires, cactus. |
| `goat` | Deux chèvres, blé. |
| `ocelot` | Deux ocelots, morue ou saumon crus. |
| `panda` | Deux pandas, bambou et environnement de reproduction natif ; gènes hérités. |
| `strider` | Deux arpenteurs, champignons biscornus. |
| `turtle` | Deux tortues, herbes aquatiques ; œuf de bébé à la place de la ponte. |
| `villager` | Reproduction native des villageois : nourriture suffisante et lit libre accessible. L’œuf donne un bébé. |
| `wolf` | Deux loups apprivoisés, nourriture native ; propriétaire hérité. |
| `allay` | Duplication native : allay dansant avec un jukebox, éclat daméthyste. Délai de 5 minutes conservé. |
| `axolotl` | Deux axolotls, poissons tropicaux en seau. |
| `hoglin` | Deux hoglins, champignons carmin. |
| `mooshroom` | Deux champimeuhs, blé. |
| `nautilus` | Deux nautiles apprivoisés, nourriture acceptée par la reproduction native. |
| `sniffer` | Deux renifleurs, graines de plante torche ; œuf dapparition à la place de l’œuf bloc. |
Pour les suivants : casser le générateur avec un outil adapté, sans Toucher de
soie. Un œuf par générateur ; Fortune ne multiplie pas le résultat. Les outils
avec Toucher de soie ne donnent pas d’œuf ; cette livraison ne leur ajoute pas
non plus une récupération du bloc générateur.
| Espèce (identifiant natif) | Source vanilla si présente |
| --- | --- |
| `skeleton` | Générateur de donjon. |
| `spider` | Générateur de donjon. |
| `zombie` | Générateur de donjon. |
| `cave_spider` | Générateur de mine abandonnée. |
| `blaze` | Générateur de forteresse du Nether. |
| `magma_cube` | Générateur de salle au trésor de bastion. |
| `silverfish` | Générateur de la salle du portail du stronghold. |
## Propositions — pas encore implémentées
Les pourcentages ci-dessous sont des valeurs de départ à valider. Les captures
**convertissent le mob en œuf** et ne laissent pas son double dans le monde.
Pour les butins et récompenses, un mob né dun œuf ne pourra pas produire son
propre œuf par cette voie. Les récompenses uniques devront avoir un suivi serveur
persistant : contrat de migration à définir lors du prochain ticket. Ces règles
ne sont pas encore ajoutées au jeu.
| Espèce | Type de voie | Proposition concrète |
| --- | --- | --- |
| `bat` | Interaction | Capturer une chauve-souris endormie avec un sac et une boule de slime. Le mob devient un œuf ; sac rendu. |
| `cod` | Capture | Morue en seau + œuf de poule : convertir le poisson en œuf dapparition ; seau vide rendu. |
| `frog` | Interaction | Élever un têtard jusqu’à la métamorphose, puis donner une boule de slime : un œuf de grenouille, une seule fois par grenouille élevée. |
| `salmon` | Capture | Saumon en seau + œuf de poule ; seau vide rendu. |
| `slime` | Butin conditionnel | Tuer un petit slime au corps à corps : 5 % de chance, uniquement sur la génération issue dun slime naturel. |
| `squid` | Capture | Interagir avec un poulpe sous leau avec un sac et une morue : le convertir en œuf ; sac rendu. |
| `tropical_fish` | Capture | Poisson tropical en seau + œuf de poule ; conserver exactement ses couleurs et son motif. |
| `copper_golem` | Construction | Construire un golem de cuivre puis lui faire terminer 16 tris réels : un œuf, une seule fois par golem construit. |
| `dolphin` | Exploration | Nourrir un dauphin et atteindre le trésor quil indique : un œuf à la découverte, une fois par trésor et joueur. |
| `drowned` | Butin conditionnel | Noyé naturel tué sous leau par un joueur : 5 %. |
| `glow_squid` | Capture | Même capture que le poulpe, dans une caverne immergée ; conversion du mob en œuf lumineux. |
| `husk` | Butin conditionnel | Zombie momifié naturel tué dans le désert en plein jour : 5 %. |
| `parrot` | Interaction | Apprivoiser un perroquet puis lui offrir des graines pendant sa danse au jukebox : un œuf, une seule fois par perroquet sauvage apprivoisé. |
| `pufferfish` | Capture | Poisson-globe en seau + œuf de poule ; seau vide rendu. |
| `polar_bear` | Interaction | Donner du saumon à deux adultes sur neige : reproduction Sanctuary spécifique donnant un œuf de bébé, délai de 5 minutes. |
| `snow_golem` | Construction | Monter le golem de neige natif puis lemballer avec une citrouille sculptée : conversion du golem en œuf. |
| `bogged` | Épreuve | Après une vague terminée, toucher son générateur d’épreuves avec un œuf de poule : un œuf de bogged, une fois par joueur et recharge native. |
| `parched` | Butin conditionnel | Parched naturel vaincu dans le désert avec un bouclier après avoir bloqué sa flèche : 5 %. |
| `breeze` | Épreuve | Même extraction après victoire sur les vagues de Breeze ; coût dun œuf de poule, recharge native du générateur d’épreuves. |
| `camel_husk` | Rencontre | Désarçonner les cavaliers hostiles puis apprivoiser la monture avec sa nourriture native : un œuf, une seule fois pour cette monture sauvage. |
| `creeper` | Butin conditionnel | Creeper naturel tué par un squelette, comme pour les disques : 10 %. |
| `endermite` | Interaction | Toucher une endermite apparue dune perle avec un fruit de chorus : conversion en œuf avant sa disparition. |
| `guardian` | Butin conditionnel | Gardien naturel vaincu à proximité dun monument : 5 %. |
| `phantom` | Butin conditionnel | Phantom naturel vaincu dans les airs avec une arme de mêlée : 10 %. |
| `piglin` | Troc | Ajouter l’œuf au troc contre un lingot dor : 2 %, poids exact à équilibrer. |
| `pillager` | Butin conditionnel | Pillard capitaine naturel vaincu par un joueur : 10 %. |
| `sulfur_cube` | Capture | Cube de soufre en seau + œuf de poule : convertir le contenu du seau en œuf, avec taille et contenu conservés. |
| `trader_llama` | Commerce | Offre rare du marchand ambulant : œuf de lama de marchand contre émeraudes et botte de paille. Les lamas ordinaires restent obtenus par reproduction. |
| `wandering_trader` | Commerce | Terminer toutes les offres dun marchand : une offre finale d’œuf contre 32 émeraudes. Une fois par marchand naturel. |
| `zombie_horse` | Rencontre | Libérer puis apprivoiser un cheval-zombie sauvage avec la nourriture native : un œuf, une seule fois par monture. |
| `zombie_villager` | Guérison | Guérir un villageois zombie naturel : un œuf de villageois zombie remis au soigneur ; un seul par individu, sans boucle de réinfection. |
| `zombified_piglin` | Butin conditionnel | Piglin zombifié naturel vaincu au Nether par un joueur : 5 %. |
| `stray` | Butin conditionnel | Vagabond naturel vaincu pendant une chute de neige : 10 %. |
| `vindicator` | Butin conditionnel | Vindicator vaincu dans un manoir ou un raid : 5 %. |
| `zoglin` | Transformation | Amener un hoglin dans lOverworld et le voir devenir zoglin : première interaction avec un champignon carmin remet un œuf, une seule fois. |
| `creaking` | Exploration | Neutraliser un grinceur lié puis casser son cœur actif sans Toucher de soie : un œuf. Le cœur ne donne pas simultanément sa version récupérable. |
| `elder_guardian` | Boss | Vaincre un grand gardien du monument : un œuf garanti, uniquement pour les gardiens générés avec le monument. |
| `enderman` | Butin conditionnel | Enderman naturel vaincu dans lEnd par un joueur : 5 %. |
| `evoker` | Butin conditionnel | Évocateur vaincu dans un manoir ou un raid : 10 %. |
| `ghast` | Défi | Tuer un ghast naturel en lui renvoyant sa propre boule de feu : 25 %. |
| `happy_ghast` | Élevage | Réhydrater un ghast desséché puis élever le ghastling : premier nourrissage adulte avec une boule de neige remet un œuf de ghastling, une fois par ghast élevé. |
| `iron_golem` | Construction | Construire un golem de fer puis lui offrir un coquelicot et un lingot : conversion volontaire du golem en œuf. |
| `piglin_brute` | Exploration | Piglin barbare du bastion vaincu par un joueur : 10 %. |
| `ravager` | Raid | Dernier ravageur vaincu dans un raid remporté : un œuf au vainqueur, une récompense par raid. |
| `shulker` | Exploration | Shulker dune cité de lEnd vaincu par un joueur : 10 % ; les copies par projectiles ne déclenchent pas cette voie. |
| `skeleton_horse` | Événement | Survivre au piège de cavaliers squelettes puis monter un cheval libéré : un œuf, une récompense par piège. |
| `vex` | Défi | Tuer un vex invoqué après avoir vaincu l’évocateur qui la invoqué : 10 %, une récompense maximum par évocateur. |
| `witch` | Butin conditionnel | Sorcière naturelle vaincue pendant quelle boit une potion : 10 %. |
| `wither_skeleton` | Butin conditionnel | Wither squelette naturel vaincu dans une forteresse : 5 %, indépendant du crâne. |
| `zombie_nautilus` | Rencontre | Libérer un nautile-zombie de son cavalier puis lapprivoiser : un œuf, une seule fois par monture sauvage. |
| `ender_dragon` | Boss | Vaincre le dragon invoqué par le cycle natif de lEnd : un œuf au participant désigné ; jamais pour un dragon apparu avec un œuf. |
| `warden` | Défi | Vaincre un Warden sorti dun hurleur naturel : un œuf garanti pour ce combat ; aucun œuf sur un Warden issu d’œuf. |
| `wither` | Boss | Vaincre un Wither invoqué par sable des âmes et crânes : un œuf garanti ; aucun œuf sur un Wither issu d’œuf. |
## Points à décider ensemble
- Le principe de conversion avec un sac / un seau pour les espèces capturables.
- La place des défis et du butin rare, et leurs chances exactes.
- Les récompenses de boss garanties, avec exclusion des boss issus d’œuf.
- Les offres du marchand et le troc restent de simples échanges natifs ; aucun
système d’économie, de banque ou de marché nest introduit ici.
Cas à ne pas confondre : nourrir ne veut pas toujours dire reproduire.
Le ghast heureux, le dromadaire momifié et le cheval-zombie refusent le mode
amoureux natif ; le nautile-zombie na pas de descendance native. Le lama de
marchand suit la reproduction du lama et ne garantit pas un nouvel œuf de
`trader_llama`. Le cube de soufre nemprunte pas la reproduction d`Animal`.
Les œufs pondus naturellement par une poule et leur lancer natif restent inchangés :
cest laccouplement nourri par le joueur qui produit le nouvel œuf garanti.
Sources de vérification : catalogue `CompanionType`, classes et données du JAR
Minecraft **26.3** installé (méthodes de reproduction, composants ditems et
aliments natifs), tests intégrés EGGS-107. Le catalogue ne prétend pas quun
comportement proposé est déjà une règle vanilla.
+172
View File
@@ -0,0 +1,172 @@
# STAT-01 — Atelier dargile et modèles 3D — beta.106
Contrat du 17 septembre 2026, branche `codex/statuary-import-beta106`.
Livraison locale vérifiée, cycle natif et archives normale/Test validés.
Aucune publication ni installation personnelle.
## Parcours retenu avec le créateur
Un seul dossier utilisateur : `schematics/sanctuary/`. La lecture historique
au premier niveau de `schematics/` reste possible, sans déplacement de fichiers.
Les boutons des deux ateliers ouvrent exactement le même dossier.
- **Métabli → Bibliothèque** : schémas `.litematic`, `.schem`, `.nbt` et `.schematic`
historiques, avec leurs règles dimport existantes.
- **Métabli → Statues → Mes modèles 3D** : modèles GLB, hauteur 496 blocs et
palette. La conversion produit un plan et rejoint les commandes K, matériaux,
export et règles de placement créatif/survie existants. Les statues des
créatures découvertes gardent leur accès habituel.
- **Atelier dargile** (`sanctuary:clay_workshop`) : choix du GLB et aperçu à
proportions conservées, limité à 16 × 16 × 16 voxels dans un seul bloc.
Un emplacement reçoit largile, lautre fournit la statue. **Un bloc dargile
est consommé pour chaque statue récupérée**, y compris en créatif.
Les blocs dargile normale conservent les couleurs du modèle ; les seize
argiles Sanctuary imposent leur couleur unie. Ni boule dargile ni terre
cuite nest acceptée. Fermer latelier restitue largile non consommée.
Recette de latelier : un bloc dargile au centre supérieur, trois dalles de
pierre lisse au milieu, trois planches en bas. Le bloc utilise les textures
actives de planches, argile et pierre lisse. Le résultat, **Statuaire**
(`sanctuary:statuary`), est un objet transportable et posable. Sa miniature se
voit dans linventaire et le monde. Il nouvre pas un éditeur lorsquon le pose.
Le fichier se choisit dans la liste ou par dépôt sur l’écran de latelier.
« Actualiser » relit le dossier. La rotation proposée à latelier dargile
est un quart de tour. Il ny a pas d’éditeur de sommets ou danimations.
## Identité des modèles et sauvegardes — contrat préalable
Les identifiants des anciens blocs, formats et générations restent inchangés.
Les nouveaux blocs napparaissent que par fabrication ou placement volontaire.
Aucun monde personnel nest ouvert et aucun chunk existant nest régénéré.
Chaque GLB reçoit un identifiant `sanctuary:model/<SHA-256 du fichier source>`.
Renommer ou déplacer un fichier ne change pas cet identifiant. Réexporter avec
un contenu binaire différent produit un nouvel identifiant. Les différentes
argiles et rotations du même modèle conservent lidentité de la source.
Il sagit dune identité de modèle dans les données, et non dun nouvel
identifiant de bloc enregistré pour chaque import. Elle ne donne aucun droit
serveur et ne désigne ni une URL ni un chemin à ouvrir.
Le statuaire a une BlockEntity dédiée avec un champ `sculpture`, schéma **1** :
nom, identifiant du modèle, dimensions et couples position/couleur ARGB des
voxels. La position est bornée à 16³, la couleur opaque, les doublons refusés.
Lobjet transporte ces données dans son composant natif de bloc ; pose, butin,
repose et sauvegarde utilisent le cycle Minecraft. Le bloc réplique les données
aux clients qui chargent son chunk. Aucun joueur na besoin du fichier source
pour voir une statue déjà fabriquée. Un schéma inconnu ou invalide chargé dans
un bloc est préservé sans être interprété ni édité.
Latelier utilise un menu natif temporaire, sans stock caché persistant.
Le serveur vérifie le menu actif, son jeton, la proximité, le bloc, les droits
et le veto dutilisation Fabric. La sélection envoie seulement des voxels bornés
(un paquet atomique de moins de 32 Kio), jamais un chemin ou un fichier exécutable.
La teinte vient du véritable objet dargile côté serveur et chaque retrait du
résultat consomme une unité. La fermeture ou la destruction de latelier annule
le droit de produire ; le travail local tardif ne change pas un autre menu.
## Contrat dimport et limites
GLB **2.0 autonome**, scène par défaut (ou première scène), triangles indexés
ou non, transformations hiérarchiques matrice ou TRS, matériaux de couleur
et textures PNG/JPEG intégrées, UV avec répétition/clamp/miroir. Le matériau
opaque ou à découpe alpha est voxelisé en surface. Le calcul seffectue sur
un fil de travail ; les faces visibles du résultat sont mises en cache pour
le rendu. Lextension de matériau non éclairé peut être lue pour sa couleur.
Limites explicites : fichier 8 Mio, JSON 1 Mio, hiérarchie de 64 niveaux,
16 384 triangles, 4 194 304 pixels décodés et autant de pixels de matériaux.
Les plans gardent les plafonds historiques de cellules et de volume.
Les grands scans demandent donc souvent une simplification préalable.
Squelettes et morphing doivent être figés avant export. Les animations ne sont
pas jouées (la pose statique des nœuds est utilisée). Couleurs de sommets,
accessors sparse, primitives non triangulaires, transparence mélangée,
compressions et extensions de textures non gérées sont refusés explicitement.
Les couleurs de sommets doivent être converties en texture. Aucun téléchargement
ni référence externe du GLB nest résolu. OBJ, FBX, STL, VOX et glTF multifichier
ne font pas partie de ce premier import. Un GLB sans surface visible est refusé.
Une miniature représente au maximum 4096 voxels de 1/16 de bloc, centrés en X/Z,
posés au sol. Les petits détails peuvent disparaître. Sa collision est le cube
contenant la sculpture ; les voxels ne sont pas des blocs exploitables ou des
inventaires. Les argiles utilisent la palette de couleur publiée du mod, avec
éclairage du rendu, sans texture de matière distincte sur chaque voxel.
Aucune dépendance tierce supplémentaire : Minecraft **26.3**, Fabric et Java 25
restent ceux du dépôt. Lecture suivant la [spécification Khronos glTF 2.0](https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html).
## Vérifications
`Statuary106ClientChecks` passe sur un client natif Minecraft 26.3 avec serveur
intégré, dans un nouveau monde plat jetable, graine 106. Il vérifie :
- Lecture dun GLB texturé avec transformation de scène, conservation des
proportions et des couleurs ; refus des fichiers hors limites, références
externes, offsets invalides et hiérarchies cycliques.
- Import indépendant de [BoxTextured de Khronos](https://github.com/KhronosGroup/glTF-Sample-Assets/tree/main/Models/BoxTextured),
triangles indexés et texture embarquée. Le fichier nest pas distribué avec le mod.
- Dossier partagé avec filtres distincts, rotation, identité du modèle et
recoloration par les seize argiles.
- Fabrication native en survie : absence de résultat sans argile, coût dune
unité, restitution du reliquat à la fermeture et production par Maj-clic.
- Pose par clic natif dans la portée Sanctuary du joueur, butin complet,
repose, rendu dans le monde, dans linventaire et dans laperçu.
- Même source convertie au Métabli en plan de 32 blocs de hauteur, sans
modifier une miniature existante ; annulation dun import à la fermeture.
- Sauvegarde, suppression du GLB, réouverture du monde : modèle transmis au
nouveau client et sculptures colorées conservées dans linventaire.
- Captures FR/EN relues dans `build/statuary106-evidence/`.
Commande de reproduction :
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryStatuary106ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Le contrôle de régression `Plans072ClientChecks` passe également sur la copie
livrée en **47 s** : **88 modèles natifs, aucun refus**, textures UV, palette
active, statues de 32 blocs, rotations, matériaux, export gzip, aperçu, confirmation
et annulation en créatif, constructions, refus dusurpation et pose payée en
survie. Commande identique avec `-PsanctuaryPlans072ClientTests=true` à la place
de `-PsanctuaryStatuary106ClientTests=true`. Journal :
`build/statuary106-plans-regression.log`.
Les premiers échecs ont permis de corriger le champ natif `recipes` du critère
de découverte de recette. Les attentes du test ont ensuite été ajustées pour
attendre la confirmation serveur des clics et respecter la portée de pose du
nouveau joueur. Aucun contournement de ces règles nest ajouté au mod.
## Livraison locale
La copie `build/release-beta106` part de la livraison beta.105 et ajoute les
30 fichiers de production concernés par ce ticket. Les sources beta.107 des
œufs, arrivées en parallèle dans le dossier partagé, restent conservées dans
leur propre livraison. `build/statuary106-source-manifest.json` décrit la copie.
La commande suivante a réussi sur cette copie en **5 min**, **132 tâches** :
```sh
./gradlew check build assemblePack assembleTestPack :sanctuary:runClientGameTest \
-x :sanctuary:runGameTest \
-PsanctuaryClientTests=true -PsanctuaryStatuary106ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Le GameTest dédié reste exclu conformément au refus antérieur daccepter son
EULA. Le serveur intégré a vérifié les écritures, le coût, le butin, la sauvegarde
et la transmission au client. Les essais ont tourné sur macOS ; une connexion
depuis deux ordinateurs et les autres pilotes graphiques nont pas été testés.
Les sources JAR correspondent exactement aux sources Java de la copie isolée.
Les deux archives contiennent le même JAR Sanctuary, avec métadonnées beta.106
et Minecraft 26.3. Aucune classe de test ni modèle GLB de test nest distribué.
Rapport et empreintes : `build/statuary106-artifact.json`.
- [Pack normal](../build/Sanctuary-beta.106.mrpack), 10 144 169 octets.
SHA-256 : `b8b76ba914c8ceaed1b1d25f0817bd4cdeb7a4bd5ad43222c6970162811ff901`.
- [Pack de test](../build/Sanctuary-Test-beta.106.mrpack), 10 163 092 octets.
SHA-256 : `e55d5871f1e31d3ed46de32d748eba51be2029c60462cf0155be90b8dd40118b`.
Aucun tag publié, canal packwiz avancé ou déploiement dans Prism.
+102
View File
@@ -0,0 +1,102 @@
# beta.097 — crash de préparation des temples
Ticket local EXP-03, branche `codex/expedition-temple-crash-beta097`.
Rapport du 16 septembre 2026 à 13:12:19, Sanctuary beta.089, Minecraft
26.3-pre-2 / Windows. Graine du monde : `-4700804240597771092` ; graine
locale de Kai/Efe : `7023521385479800534`.
Le chargement s'arrête dans `ExpansionStructures23.ancientTemples002` : aucun
site accepté pour `minecraft:desert_pyramid` après 1 926 colonnes. La recherche
historique couvre seulement 72 % du rayon nominal de l'île. Le défaut existe
encore dans beta.096 sur Minecraft 26.3 finale.
## Contrat avant correction
Les identifiants, biomes, graines, centres des îles et journaux restent inchangés.
Le relief reste identique, sauf lassise locale explicitement autorisée pour un
temple de secours lorsquaucun sol naturel ne convient. Aucun monde personnel n'est ouvert/modifié,
aucun chunk joué n'est régénéré, aucune expansion n'est activée.
Conserver la recherche historique et son ordre exact en premier : les sites
admissibles déjà trouvés ne doivent pas changer. Étudier une recherche de
secours finie dans les autres chunks de l'île uniquement après l'échec de cette
recherche, avec les mêmes règles natives de biome, de pente, de fondation et
de protection. Après l’échec réel de cette recherche sur la graine du rapport, le créateur a
explicitement demandé de **forcer la présence du temple**. Un dernier recours
classe les sites intérieurs par pente et utilise les pièces vanilla, même si
le biome exact nest pas celui requis pour leur apparition spontanée. La
fondation naturelle est testée en premier ; sinon une assise locale est
préparée sous le bâtiment. Aucun temple manquant nest déclaré présent. Le budget de calcul
reste borné. Les cas auparavant réussis doivent conserver leurs coordonnées.
La vérification utilise exclusivement de nouveaux mondes de développement et
leur réouverture, avec le client et son serveur intégré. Aucun serveur dédié
ni nouvelle acceptation d'EULA.
La recherche naturelle élargie échoue également sur la graine du rapport
(440 candidats supplémentaires, 2 366 colonnes). Le dernier recours trouve une
pyramide au chunk `[-4, 43]`, sur sol naturel, sans assise ajoutée pour cette
reproduction. Le temple de jungle reste au chunk `[43, 2]`.
La recherche historique garde 32 768 colonnes au maximum, portée à 65 536 pour
la recherche élargie. Le dernier recours utilise un échantillonneur distinct
borné à 65 536 colonnes, classe les candidats intérieurs par pente, et examine
au plus 32 bâtiments complets. La variante au sol naturel est privilégiée ;
lassise, si nécessaire, garde les vérifications demprise, de hauteur et de
protection. Les contraintes naturelles exactes de biome/pente ne peuvent alors
plus annuler à elles seules lexistence du temple demandé.
## Contrat du complément NBT
Le champ entier optionnel `sanctuary_native.temple_foundation_min_y` accompagne
seulement les nouveaux starts exigeant une fondation. Son absence conserve le
comportement antérieur ; aucun champ nest ajouté aux starts ordinaires. La
fondation en grès ou pierre moussue est produite pendant la décoration native,
uniquement dans le chunk en cours, avant la construction vanilla des salles,
coffres et pièges. Elle ne sexécute jamais au chargement dun chunk déjà généré.
Le cache natif conserve ce complément via la sérialisation existante des starts.
Aucun journal ni chunk historique nest migré. Les protections spatiales restent
actives, et lassise est incluse dans la réservation du temple.
## Aperçu des plans (demande complémentaire)
Le créateur demande aussi didentifier les matériaux directement dans laperçu.
Les modèles et UV du pack actif remplacent les aplats ; états/orientations,
formes non cubiques et couleurs de biome sont conservés. Les conflits gardent
un contour rouge, les blocs déjà posés disparaissent de laperçu. Le nom du bloc
visé est affiché. Rendu seulement : aucun bloc nest posé par cet affichage.
## Vérifications beta.097
- Avant correction, reproduction sur 26.3 finale du même échec que beta.089 :
graine locale `7023521385479800534`, exactement 1 926 colonnes échantillonnées.
- `Temple097ClientChecks` : création réelle avec la graine du rapport, puis
réouverture du monde de développement et restauration du cache. Deux temples
natifs complets : pyramide **4 coffres et 9 TNT**, jungle **2 coffres et
2 distributeurs**. Starts NBT, pièges, blocs et localisation identiques après
reprise. Réussi en 3 min 30 s. Le premier essai corrigé avait déjà chargé le
monde, mais son pilote attendait au Hello World ; lautomatisation de cette
étape a ensuite été ajoutée au test.
- Régression du Métabli avec les nouveaux modèles : sélection, K, génération,
import/export, pose payée, rotation, masquage, interfaces FR/EN et huit
matériaux dont coffre, escalier, four et feuillage. Les contrôles de fondation
grès/pierre moussue vérifient aussi le complément NBT, la continuité des
écritures, leur découpage strict par chunk et labsence d’écriture sur un
ancien start non marqué. La fondation est vérifiée via un WorldGenLevel de
test ; la graine réelle nen a pas besoin.
- Captures inspectées dans `build/preview097-evidence/`. Les modèles ordinaires
sont translucides, les modèles spéciaux gardent leur rendu natif. La sélection
graphique est bornée aux 4 096 blocs les plus proches dans le champ de vision,
à moins de 96 blocs ; les matériaux/comptages concernent toujours le plan entier.
Les journaux sont `build/temple097-repro.log`, `build/temple097-fallback.log`,
`build/temple097-client.log`, `build/preview097-client.log` et
`build/beta097-check.log`. Le reçu `build/beta097-artifact.json` vérifie les
archives, les sources embarquées et la conservation des textures existantes.
Aucun essai Windows ni shader tiers nest revendiqué. Aucun monde personnel ni
instance Prism na été ouvert ou mis à jour ; le canal packwiz reste inchangé.
Validation finale : `./gradlew check build assemblePack assembleTestPack
-x :sanctuary:runGameTest` réussit en **2 min 13 s**, 124 tâches. La suite native
Métabli/aperçu/fondations réussit en **59 s**. Archives normal et Test beta.097
exportées et vérifiées ; les archives beta.096 restent inchangées.
+85
View File
@@ -0,0 +1,85 @@
# beta.093 — Recherche créative et infobulles des familiers
Branche `codex/async-tooltip-crash-beta093`, Minecraft 26.3.
## Diagnostic
Le rapport fourni concerne Windows, Sanctuary beta.089 et Minecraft 26.3-pre-2.
Pendant la saisie d'un `w`, la recherche créative attend son index asynchrone.
Cet index construit les infobulles sur un worker (`SessionSearchTrees`), puis
`CompanionTooltips` appelle `Font.getSplitter().splitLines`. La mesure d'un glyphe
non encore chargé peut déclencher une écriture de texture, en concurrence avec
une passe de rendu : `Close the existing render pass before performing additional
commands`. L'exception du worker remonte ensuite par la saisie de caractères.
La même mise en page était encore présente en beta.092 ; le correctif cible la
version actuelle 26.3 finale. Il ne nécessite aucun changement de pilote graphique.
## Correction
Les deux chemins de descriptions, catalogue actuel et pouvoirs historiques,
utilisent un même point de mise en page. Sur le fil client, il conserve la largeur
adaptée à la fenêtre et le découpage habituel. Sur un worker, il renvoie des
composants textuels complets avec leur couleur, sans accéder à la police ni à la
fenêtre. La recherche garde les noms et descriptions ; aucune ligne n'est cachée
pour contourner le problème. Aucun travail graphique n'est renvoyé au fil client
avec attente, ce qui éviterait mal le crash et risquerait de bloquer l'indexation.
Aucune modification de sauvegarde, génération, règles de jeu, images ou registres.
Aucun déploiement dans une instance personnelle et aucun monde existant ouvert.
## Vérifications
Le parcours `Tooltip093ClientChecks` utilise un monde plat de développement neuf,
graine 42, et une sonde uniquement dans les GameTests. Cette sonde interdit tout
accès de mesure à la police depuis un worker, même lorsque ses glyphes sont déjà
en cache ; elle n'est pas embarquée dans le mod distribué.
Avant le correctif, le test échoue dans `CompanionTooltips` avec
`TOOLTIP093_OFF_THREAD_FONT`, ce qui reproduit l'accès interdit identifié dans le
rapport sans dépendre d'une course aléatoire du pilote. Journal :
`build/tooltip093-before-client.log`.
Après correction, le même parcours passe en **1 min 13 s** sur le client natif
Minecraft 26.3/macOS :
- 88 œufs, français/anglais, descriptions cryptiques/détaillées : construction
sur worker sans aucune mesure de police.
- Comparaison du texte indexé avec le texte affiché : contenu complet conservé,
malgré les retours à la ligne réservés à l'affichage.
- `SessionSearchTrees.updateCreativeTooltips`, recherche native et reconstruction
de l'index après changement de langue.
- Rechargement effectif des ressources, puis saisie native de `w` et `olf` dans
la recherche créative. Capture anglaise inspectée : le champ contient `wolf`
et affiche l'œuf de loup et son armure. Captures conservées dans
`build/tooltip093-evidence/`.
```sh
./gradlew :sanctuary:runClientGameTest -x :sanctuary:runGameTest \
-PsanctuaryClientTests=true -PsanctuaryTooltip093ClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryClientNoVsync=true
```
Journal : `build/tooltip093-client.log`, marqueur `TOOLTIP093_PASS`.
## Livraison vérifiée
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 36 s**, 124 tâches (`build/tooltip093-check.log`).
Les exports, JAR et sources correspondent. Seule la classe de production
`CompanionTooltips` diffère de beta.092 ; aucune sonde GameTest n'est distribuée.
Images, données, JEI et anciennes archives restent identiques.
Reçu local : `build/tooltip093-artifact.json`.
- [Pack normal](../build/Sanctuary-beta.093.mrpack), SHA-256
`35a1582c044955579a7120997864ff2080e276e81180ab9982397241ff4c3a1d`.
- [Pack Test](../build/Sanctuary-Test-beta.093.mrpack), SHA-256
`48c9abe733c2993a752413a34852223d35ad33e99d4f7e1d4f284969824dc275`.
## Limites
Le rapport initial est Windows/pre-2 ; les essais du correctif sont macOS/26.3
finale, avec serveur intégré de développement. L'accès fautif est reproduit
avec une sonde déterministe, sans revendiquer la reproduction exacte de la
course du pilote AMD. Serveur dédié exclu et aucune EULA acceptée. Les archives
locales ne mettent pas à jour une installation personnelle ni le canal packwiz.
+129
View File
@@ -0,0 +1,129 @@
# TOOL-02 — Clé dorée et clé de Steve — beta.123
Contrat du 17 septembre 2026, branche `codex/wrench-safety-beta123`.
Ce ticket remplace le contrat de la clé dorée de beta.120 : l'édition libre
reste disponible dans un objet distinct, la **Clé à molette de Steve**.
Le créateur précise que la clé de Steve reste utilisable en survie dès quun
joueur la récupère, sans condition opérateur. Elle na pas de recette.
## Clé dorée : orientations natives
L'identifiant `sanctuary:golden_wrench`, sa recette et sa texture restent stables.
Clic droit parcourt l'orientation native ; Maj parcourt en sens inverse.
Clic gauche rappelle les gestes, sans casser le bloc ni sélectionner un état.
Les familles de propriétés autorisées sont les orientations horizontales,
les six directions, les trois axes, la rotation native des panneaux et
l'orientation native des blocs qui la proposent. La règle porte sur les
propriétés Minecraft, sans catalogue à maintenir pour chaque type de bloc.
L'eau, la moitié d'un escalier, les inventaires, les états de croissance,
les quantités, la puissance et les autres propriétés ne sont jamais proposées.
Aucune transformation indépendante des textures n'est créée par la clé dorée.
Les textures suivent seulement le modèle orienté par Minecraft.
Les connexions et formes sont recalculées à partir des voisins réels, avec les
mises à jour natives actives. Une orientation privée de support est refusée
sans casser le bloc. L'eau déjà contenue dans un bloc est conservée.
Les coffres doubles, portes, lits, étagères reliées, pistons sortis et leurs
pièces mobiles ne peuvent pas être tournés individuellement. Ils restent dans
leur état courant et une indication explique le refus. Il faut replacer les
éléments concernés ou rétracter le piston ; ce ticket ne déplace pas un
ensemble lié. Les coffres simples conservent leur entité et leur inventaire.
L'assemblage, l'ouverture et la dissociation des Métablis, Fûts, Fourneaux et
super pistons restent prioritaires avec les deux clés. Les parties techniques
d'un multibloc Sanctuary assemblé ne sont pas éditées individuellement.
## Clé de Steve
Nouvel identifiant `sanctuary:steve_wrench`, dans la section créative des outils,
sans recette de survie. La possession de cet objet donne accès à ses pouvoirs,
y compris en survie sans commandes et sans statut opérateur, dans les deux
mains. Les restrictions natives de construction, aventure, spectateur, portée,
protection et verrouillage restent appliquées côté serveur.
Les gestes de beta.120 sont conservés : clic gauche choisit la propriété,
clic droit la modifie, Maj inverse le parcours. Tous les états natifs et la
rotation indépendante des textures y restent volontairement accessibles.
La clé de Steve est donc volontairement capable de produire des états non
naturels, y compris pour le joueur qui la récupère en survie.
La texture finale est le PNG 16 × 16 bleu fourni par le créateur dans ce ticket,
copié sans transformation dans `assets/sanctuary/textures/item/steve_wrench.png`.
SHA-256 : `0084c41e1f46958903075e21866facd97d85e03fb8e06742e25b2ab254956fa3`.
Le modèle reste un objet Minecraft 2D tenu en main, avec rareté épique et reflet.
La proposition imagegen intermédiaire n'est pas livrée. Les noms, messages et
indications des deux outils existent en français et en anglais.
## Sauvegardes et ressources
Aucune migration, modification de génération ni parcours des mondes existants.
Les transformations de texture précédemment sauvegardées sont conservées ;
la clé de Steve permet de les modifier. Les états non naturels déjà présents
ne sont pas automatiquement réécrits. Les clés dorées existantes reçoivent
immédiatement les nouvelles restrictions par leur identifiant inchangé.
Le template personnel inclut le nouvel objet à sa prochaine création ; une
copie personnelle existante n'est jamais écrasée.
## Vérifications
Le parcours `Wrench123ClientChecks` passe en **1 min 12 s**, client macOS et
serveur intégré, Minecraft 26.3, dans des mondes plats jetables de graines
123 et 120. Il reprend le parcours avancé de beta.120 sur la clé de Steve :
- Vrais clics dorés répétés sur escalier, coffre et terre : pas de nouvelle eau,
pas de sélection libre, pas de faux coffre double ni de rotation de texture.
- Rotation inverse, eau existante conservée, formes recalculées selon les voisins,
trois axes de bûches et six orientations des pistons isolés.
- Coffre double avec ses deux inventaires intacts ; portes, lits, pistons sortis
et pièces mobiles protégés ; torche murale sans support laissée intacte.
- Vrais clics Steve en survie sans commandes : états et textures accessibles,
dans les deux mains. Créatif accessible aussi sans commandes ; restrictions
aventure/spectateur/portée conservées.
- **2 522 propriétés natives** toujours réversibles pour Steve, UV réellement
tournés sans déplacer la géométrie, rechargement des ressources, inventaires
et sauvegarde/reconnexion conservés.
- Assemblage et dissociation des quatre familles avec la clé dorée par vrais
clics ; le PNG de Steve apparaît en main et dans la barre rapide.
Marqueurs `WRENCH123_SAFE_PASS`, `WRENCH123_PASS`, `WRENCH120_PASS` dans
`build/wrench123-client.log`. Captures relues dans `build/wrench123-evidence/`.
Pas de validation Windows, de client distant ou de shader tiers.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 19 s**, 126 tâches (103 exécutées, 23 à jour).
Les sources des JAR correspondent aux sources du dépôt. La comparaison avec
beta.122 limite les dix entrées de production modifiées aux classes des clés,
aux libellés et aux ressources du nouvel objet. Les autres ressources,
notamment les sculptures et la texture dorée, sont identiques.
Les packs normal/Test contiennent le même JAR Sanctuary, sans sauvegarde ni
fixture de test. Le template ZIP correspond exactement aux ressources du JAR
et à ses ressources embarquées, dont le PNG bleu du créateur.
- Pack normal : 10 270 954 octets, SHA-256
`a7401dbef1bd1b888da2686368943eddc82d0d43e20449330c4849b4dff93e5f`.
- Pack Test : 10 289 877 octets, SHA-256
`c57cc6fc47f887b90850473e51407b610a6c5639c35fc2b78dc9ddb74f795b80`.
- JAR Sanctuary : SHA-256
`23e1a81c793a048c6375a0da07eb0bbe1a18f833a9ceb61a3c1dc5f1a3e65a30`.
Reçu local : `build/wrench123-artifact.json`.
Le GameTest dédié reste exclu conformément au refus antérieur de son EULA.
Aucun monde personnel n'est ouvert par les essais.
## Publication et installation
La [release beta.123](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.123)
est publiée depuis `4552cc347b7fded610b1eda7a67ffaad56a5e201`.
Le tag exact et les artefacts sont immuables ; les téléchargements publics
ont été vérifiés. Canal packwiz : `f988d01b901f2d3eda0b4ab47a48540dc627d1b5`.
Le template personnel publié inclut la clé de Steve ; SHA-256
`f84d3c1c0e38232add90fb943dc39fd3e89138576df86024ceaa311478ed5cb9`.
Deux synchronisations isolées puis deux dans **Sanctuary Beta** réussissent.
Un seul JAR Sanctuary beta.123 est actif, conforme au hash vérifié ; le second
passage ne change rien. Les **923 fichiers personnels et réglages suivis**
restent inchangés, sans ouverture de monde personnel. Copie préalable dans
`sanctuary-backups/before-beta.123/` de l'instance existante.
Reçus : `build/wrench123-isolated.json` et `build/wrench123-prism.json`.
+3 -3
View File
@@ -9,8 +9,8 @@ loom_version=1.17.20
fabric_api_version=0.160.5+26.3
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
mod_version=beta.092
pack_version=beta.092
resource_pack_version=beta.090
mod_version=beta.129
pack_version=beta.129
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+95
View File
@@ -93,6 +93,48 @@ tasks.named('runGameTest') {
}
tasks.named('processGametestResources') {
def shadows122Client = providers.gradleProperty('sanctuaryPixelShadows122ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPixelShadows122ClientTests', shadows122Client)
if (shadows122Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks') }
def gems126Client = providers.gradleProperty('sanctuaryGems126ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryGems126ClientTests', gems126Client)
if (gems126Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Gems126ClientChecks') }
def sculpture125Client = providers.gradleProperty('sanctuarySculpture125ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySculpture125ClientTests', sculpture125Client)
if (sculpture125Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Sculpture125ClientChecks') }
def wrench123Client = providers.gradleProperty('sanctuaryWrench123ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryWrench123ClientTests', wrench123Client)
if (wrench123Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Wrench123ClientChecks') }
def sculpture122Client = providers.gradleProperty('sanctuarySculpture122ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySculpture122ClientTests', sculpture122Client)
if (sculpture122Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Sculpture122ClientChecks') }
def template121Client = providers.gradleProperty('sanctuaryResourceTemplate121ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryResourceTemplate121ClientTests', template121Client)
if (template121Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.ResourceTemplate121ClientChecks') }
def wrench120Client = providers.gradleProperty('sanctuaryWrench120ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryWrench120ClientTests', wrench120Client)
if (wrench120Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Wrench120ClientChecks') }
def sculpture116Client = providers.gradleProperty('sanctuarySculpture116ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySculpture116ClientTests', sculpture116Client)
if (sculpture116Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Sculpture116ClientChecks') }
def furnace115Client = providers.gradleProperty('sanctuaryFurnace115ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryFurnace115ClientTests', furnace115Client)
if (furnace115Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Furnace115ClientChecks') }
def carryPanic114Client = providers.gradleProperty('sanctuaryCarryPanic114ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryCarryPanic114ClientTests', carryPanic114Client)
if (carryPanic114Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.CarryPanic114ClientChecks') }
def statuary106Client = providers.gradleProperty('sanctuaryStatuary106ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryStatuary106ClientTests', statuary106Client)
if (statuary106Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Statuary106ClientChecks') }
def villageOnly = providers.gradleProperty('sanctuaryVillageOnly').map { it.toBoolean() }.getOrElse(false)
def focused = providers.gradleProperty('sanctuaryFocusedTests').getOrElse('').split(',').findAll { !it.isEmpty() }
def focusClasses = [plans072: 'Plans072GameTests', starter052: 'Starter052GameTests', dispenser051: 'Dispenser051GameTests', head050: 'Head050GameTests', boatshead: 'BoatsHead049GameTests', poultry: 'Poultry048GameTests', starinspect: 'StarInspection047GameTests', skyflight: 'SkyFlight046GameTests', eggaudit: 'Companion044GameTests', headblocks: 'HeadBlocks043GameTests', operator: 'OperatorStars040GameTests', starter: 'Starter039GameTests', gravelayout: 'GraveInventory039GameTests', foodknowledge: 'FoodKnowledge039GameTests', graves: 'GravesFood038GameTests', realtime: 'Realtime037GameTests', corrections: 'Corrections036GameTests', collections: 'Collections035GameTests', notifications: 'Notifications034GameTests', menus: 'Menus033GameTests', aerial: 'Aerial24WorldGameTests', transit: 'Transit29WorldGameTests', village: 'Village306GameTests', drained: 'Drained307GameTests', expeditions: 'AncientExpeditions001GameTests', progression: 'Progression003GameTests', map: 'Atlas005GameTests', demeure: 'Demeure006GameTests', mining: 'Mining008GameTests', building: 'Building009GameTests', inventory: 'Inventory010GameTests', cycle: 'Cycle011GameTests', inventoryflow: 'Inventory012GameTests', sorting: 'Inventory014GameTests', tutorial: 'Tutorial016GameTests', movement: 'Movement017GameTests', accessories: 'Accessory018GameTests', companions: 'Companion019GameTests', familiar: 'Familiar020GameTests', carry: 'Carry027GameTests', familiarhit: 'Familiar029GameTests', refonte: 'Companion032GameTests', lights: 'Dynamic030GameTests', cosmetics: 'HeadCosmetic021GameTests', startup: 'StartupPerformanceGameTests', recipes: 'Recipes024GameTests']
@@ -123,6 +165,39 @@ tasks.named('processGametestResources') {
def welcome083Client = providers.gradleProperty('sanctuaryWelcome083ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryWelcome083ClientTests', welcome083Client)
if (welcome083Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Welcome083ClientChecks') }
def melt109Client = providers.gradleProperty('sanctuaryMelt109ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryMelt109ClientTests', melt109Client)
if (melt109Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Melt109ClientChecks') }
def snow108Client = providers.gradleProperty('sanctuarySnow108ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySnow108ClientTests', snow108Client)
if (snow108Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Snow108ClientChecks') }
def eggs107Client = providers.gradleProperty('sanctuaryEggs107ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryEggs107ClientTests', eggs107Client)
if (eggs107Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Eggs107ClientChecks') }
def living105Client = providers.gradleProperty('sanctuaryLiving105ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryLiving105ClientTests', living105Client)
if (living105Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Living105ClientChecks') }
def bricks102Client = providers.gradleProperty('sanctuaryBricks102ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryBricks102ClientTests', bricks102Client)
if (bricks102Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Bricks102ClientChecks') }
def landing101Client = providers.gradleProperty('sanctuaryLanding101ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryLanding101ClientTests', landing101Client)
if (landing101Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Landing101ClientChecks') }
def schematics100Client = providers.gradleProperty('sanctuarySchematics100ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySchematics100ClientTests', schematics100Client)
if (schematics100Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Schematics100ClientChecks') }
def exploration100Client = providers.gradleProperty('sanctuaryExploration100ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryExploration100ClientTests', exploration100Client)
if (exploration100Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Exploration100ClientChecks') }
def catalogue099Client = providers.gradleProperty('sanctuaryCatalogue099ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryCatalogue099ClientTests', catalogue099Client)
if (catalogue099Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Catalogue099ClientChecks') }
def temple097Client = providers.gradleProperty('sanctuaryTemple097ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryTemple097ClientTests', temple097Client)
if (temple097Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Temple097ClientChecks') }
def metatable096Client = providers.gradleProperty('sanctuaryMetatable096ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryMetatable096ClientTests', metatable096Client)
if (metatable096Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Metatable096ClientChecks') }
def piston087Client = providers.gradleProperty('sanctuaryPiston087ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPiston087ClientTests', piston087Client)
if (piston087Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Piston087ClientChecks') }
@@ -132,6 +207,9 @@ tasks.named('processGametestResources') {
def fourneau085Client = providers.gradleProperty('sanctuaryFourneau085ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryFourneau085ClientTests', fourneau085Client)
if (fourneau085Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Fourneau085ClientChecks') }
def tooltip093Client = providers.gradleProperty('sanctuaryTooltip093ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryTooltip093ClientTests', tooltip093Client)
if (tooltip093Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Tooltip093ClientChecks') }
def pick092Client = providers.gradleProperty('sanctuaryPick092ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPick092ClientTests', pick092Client)
if (pick092Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Pick092ClientChecks') }
@@ -390,6 +468,15 @@ tasks.withType(JavaCompile).configureEach {
}
processResources {
from(rootProject.file('ressources-pack/template')) {
exclude '**/.DS_Store'
into 'resourcepacks/template'
}
from(rootProject.file('LICENSE')) {
rename { 'LICENSE_sanctuary' }
into 'resourcepacks/template'
}
from(rootProject.file('THIRD_PARTY_NOTICES.md')) { into 'resourcepacks/template' }
from(rootProject.file('ressources-pack/sanctuary')) {
include 'assets/**', 'pack.mcmeta', 'pack.png'
exclude '**/.DS_Store'
@@ -1077,3 +1164,11 @@ tasks.register('atlas059Smoke', JavaExec) {
mainClass = 'fr.koka.sanctuary.atlas.AtlasMarker059Smoke'
}
tasks.named('check') { dependsOn('atlas059Smoke') }
tasks.register('seasonal105Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.weather.Seasonal105Smoke'
}
tasks.named('check') { dependsOn('seasonal105Smoke') }
@@ -0,0 +1,104 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.building.ColoredBricks;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.registries.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.resources.ResourceKey;
import net.minecraft.tags.BlockTags;
import net.minecraft.world.item.*;
import net.minecraft.world.item.crafting.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.properties.*;
import net.minecraft.world.item.enchantment.Enchantments;
/** Native registry, recipes, loot, creative tabs and baked models in a fresh integrated world. */
public final class Bricks102ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Bricks102 "+UUID.randomUUID());s.setSeed("102");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
world.getServer().runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var level=p.level();var pos=new BlockPos(0,3,0);
check(ColoredBricks.families().size()==16,"All colors registered");Walls118Checks.server(s);int index=0;
for(var f:ColoredBricks.families()){
String color=f.color().getName();
for(var block:List.of(f.bricks(),f.slab(),f.stairs(),f.wall(),f.clay()))check(block.asItem()!=Items.AIR,"Block item exists "+block);
check(f.bricks().defaultBlockState().is(BlockTags.MINEABLE_WITH_PICKAXE),"Pickaxe tag "+color);
check(f.clay().defaultBlockState().is(BlockTags.MINEABLE_WITH_SHOVEL),"Shovel tag "+color);
var shovel=new ItemStack(Items.DIAMOND_SHOVEL);var silk=shovel.copy();silk.enchant(level.registryAccess().lookupOrThrow(Registries.ENCHANTMENT).getOrThrow(Enchantments.SILK_TOUCH),1);
drop(Block.getDrops(f.clay().defaultBlockState(),level,pos,null,p,shovel),f.clayBall(),4);
drop(Block.getDrops(f.clay().defaultBlockState(),level,pos,null,p,silk),f.clay().asItem(),1);
drop(Block.getDrops(f.slab().defaultBlockState().setValue(SlabBlock.TYPE,SlabType.DOUBLE),level,pos,null),f.slab().asItem(),2);
drop(Block.getDrops(f.slab().defaultBlockState(),level,pos,null),f.slab().asItem(),1);
drop(Block.getDrops(f.bricks().defaultBlockState(),level,pos,null),f.bricks().asItem(),1);
drop(Block.getDrops(f.stairs().defaultBlockState(),level,pos,null),f.stairs().asItem(),1);
check(f.slab().defaultBlockState().setValue(SlabBlock.WATERLOGGED,true).getFluidState().isSource(),"Slab water "+color);
check(f.stairs().defaultBlockState().setValue(StairBlock.WATERLOGGED,true).getFluidState().isSource(),"Stair water "+color);
for(String suffix:List.of("clay_ball_from_dye","clay_from_dye","brick_from_dye","bricks_from_dye","clay","bricks","brick_slab","brick_stairs","brick_slab_from_stonecutting","brick_stairs_from_stonecutting","brick_wall","brick_wall_from_stonecutting","clay_ball_smelting","clay_smelting"))check(s.getRecipeManager().byKey(ResourceKey.create(Registries.RECIPE,SanctuaryMod.id(color+"_"+suffix))).isPresent(),"Recipe loaded "+color+"_"+suffix);
craft(s,level,2,2,List.of(f.brick(),f.brick(),f.brick(),f.brick()),f.bricks().asItem(),1);
craft(s,level,2,2,List.of(f.clayBall(),f.clayBall(),f.clayBall(),f.clayBall()),f.clay().asItem(),1);
craft(s,level,3,1,List.of(f.bricks().asItem(),f.bricks().asItem(),f.bricks().asItem()),f.slab().asItem(),6);
craft(s,level,3,3,List.of(f.bricks().asItem(),Items.AIR,Items.AIR,f.bricks().asItem(),f.bricks().asItem(),Items.AIR,f.bricks().asItem(),f.bricks().asItem(),f.bricks().asItem()),f.stairs().asItem(),4);
var dye=BuiltInRegistries.ITEM.getValue(net.minecraft.resources.Identifier.withDefaultNamespace(color+"_dye"));
for(var pair:List.of(new Item[]{Items.CLAY_BALL,f.clayBall()},new Item[]{Items.CLAY,f.clay().asItem()},new Item[]{Items.BRICK,f.brick()},new Item[]{Items.BRICKS,f.bricks().asItem()}))craft(s,level,3,3,List.of(pair[0],pair[0],pair[0],pair[0],dye,pair[0],pair[0],pair[0],pair[0]),pair[1],8);
var input=new SingleRecipeInput(new ItemStack(f.clayBall()));var cooked=s.getRecipeManager().getRecipeFor(RecipeType.SMELTING,input,level).orElseThrow().value().assemble(input);check(cooked.is(f.brick()),"Smelt brick "+color);
input=new SingleRecipeInput(new ItemStack(f.clay()));cooked=s.getRecipeManager().getRecipeFor(RecipeType.SMELTING,input,level).orElseThrow().value().assemble(input);check(cooked.is(BuiltInRegistries.ITEM.getValue(net.minecraft.resources.Identifier.withDefaultNamespace(color+"_terracotta"))),"Smelt terracotta "+color);
var stoneInput=new SingleRecipeInput(new ItemStack(f.bricks()));
for(var variant:List.of(new Object[]{"brick_slab",f.slab().asItem(),2},new Object[]{"brick_stairs",f.stairs().asItem(),1},new Object[]{"brick_wall",f.wall().asItem(),1})){
var recipe=(StonecutterRecipe)s.getRecipeManager().byKey(ResourceKey.create(Registries.RECIPE,SanctuaryMod.id(color+"_"+variant[0]+"_from_stonecutting"))).orElseThrow().value();check(recipe.matches(stoneInput,level),"Stonecutting accepts input");var cut=recipe.assemble(stoneInput);check(cut.is((Item)variant[1])&&cut.getCount()==(int)variant[2],"Stonecutting result");
}
int x=(index%8)*3,z=(index/8)*5;level.setBlockAndUpdate(new BlockPos(x,1,z),f.bricks().defaultBlockState());level.setBlockAndUpdate(new BlockPos(x,2,z),f.slab().defaultBlockState().setValue(SlabBlock.TYPE,SlabType.TOP));level.setBlockAndUpdate(new BlockPos(x+1,1,z),f.stairs().defaultBlockState().setValue(StairBlock.FACING,Direction.SOUTH));level.setBlockAndUpdate(new BlockPos(x,1,z+1),f.clay().defaultBlockState());index++;
}
fr.koka.sanctuary.recipes.RecipeService.discover(p,Items.CLAY_BALL);fr.koka.sanctuary.recipes.RecipeService.refresh(p);
var known=fr.koka.sanctuary.recipes.RecipeService.knowledge(p);
check(known.recipes.contains("sanctuary:clay_workshop"),"Existing workshop recipe remains discoverable");
for(var f:ColoredBricks.families())check(known.recipes.contains("sanctuary:"+f.color().getName()+"_brick_stairs_from_stonecutting")&&known.recipes.contains("sanctuary:"+f.color().getName()+"_brick_wall")&&known.recipes.contains("sanctuary:"+f.color().getName()+"_brick_wall_from_stonecutting"),"Collection unlock "+f.color());
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
p.teleportTo(11,8,19);p.setYRot(180);p.setXRot(26);p.getAbilities().flying=true;p.onUpdateAbilities();
});
c.runOnClient(m->{
CreativeModeTabs.tryRebuildTabContents(m.level.enabledFeatures(),true,m.level.registryAccess());
var blocks=BuiltInRegistries.CREATIVE_MODE_TAB.getOrThrow(CreativeModeTabs.COLORED_BLOCKS).value().getDisplayItems();
var ingredients=BuiltInRegistries.CREATIVE_MODE_TAB.getOrThrow(CreativeModeTabs.INGREDIENTS).value().getDisplayItems();Walls118Checks.creative(new ArrayList<>(blocks));
for(var f:ColoredBricks.families()){
for(var b:List.of(f.bricks(),f.slab(),f.stairs(),f.wall(),f.clay())){
check(blocks.stream().anyMatch(i->i.is(b.asItem())),"Creative color tab "+b);
for(var state:b.getStateDefinition().getPossibleStates()){
var parts=new ArrayList<net.minecraft.client.renderer.block.dispatch.BlockStateModelPart>();m.getModelManager().getBlockStateModelSet().get(state).collectParts(net.minecraft.util.RandomSource.create(0),parts);check(b instanceof WallBlock||!parts.isEmpty(),"Baked model "+state);
for(var part:parts)for(var direction:new Direction[]{Direction.UP,Direction.DOWN,Direction.NORTH,Direction.SOUTH,Direction.EAST,Direction.WEST,null})for(var quad:part.getQuads(direction))check(!quad.materialInfo().sprite().contents().name().toString().contains("missingno"),"No missing texture "+state);
}
}
for(var item:List.of(f.clayBall(),f.brick()))check(ingredients.stream().anyMatch(i->i.is(item)),"Creative ingredients "+item);
}
});
c.waitTicks(20);c.runOnClient(m->{m.player.setYRot(180);m.player.setXRot(26);});c.waitTicks(4);c.takeScreenshot("bricks102-gallery");saved=world.getWorldSave();System.out.println("BRICKS102_NATIVE_PASS colors=16 recipes=224 blocks=80 items=112");
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);
world.getServer().runOnServer(s->{var level=s.getPlayerList().getPlayers().getFirst().level();Walls118Checks.saved(level);int i=0;for(var f:ColoredBricks.families()){var pos=new BlockPos((i%8)*3,1,(i/8)*5);check(level.getBlockState(pos).is(f.bricks()),"Saved bricks "+f.color());check(level.getBlockState(pos.above()).getValue(SlabBlock.TYPE)==SlabType.TOP,"Saved slab state");i++;}});
}
System.out.println("BRICKS102_PASS");System.out.println("WALLS118_PASS native placement, mixed connections, tall sides, waterlogging, collision, crafting, stonecutting, loot, collection, creative order, baked models and reconnect");
}
private static void craft(net.minecraft.server.MinecraftServer s,net.minecraft.server.level.ServerLevel level,int w,int h,List<Item> items,Item result,int count){var input=CraftingInput.of(w,h,items.stream().map(ItemStack::new).toList());var stack=s.getRecipeManager().getRecipeFor(RecipeType.CRAFTING,input,level).orElseThrow().value().assemble(input);check(stack.is(result)&&stack.getCount()==count,"Craft "+result);}
private static void drop(List<ItemStack> drops,Item item,int count){check(drops.size()==1&&drops.getFirst().is(item)&&drops.getFirst().getCount()==count,"Loot "+item+" expected "+count+" got "+drops);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,115 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.inventory.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.CameraType;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.CustomData;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
/** Real cursor/attack packets, synchronized player travel, interruption and collision regression. */
public final class CarryPanic114ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("CarryPanic114 "+UUID.randomUUID());s.setSeed("114");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.getInventory().clearContent();p.getAbilities().invulnerable=true;p.onUpdateAbilities();reset(p);
for(int x=-24;x<=24;x++)for(int z=-24;z<=24;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");});
int parrot=server.computeOnServer(s->carry(player(s),EntityTypes.PARROT).getId());
c.waitFor(m->m.level.getEntity(parrot)!=null&&CarryService.carried(m.level.getEntity(parrot)),150);
float health=server.computeOnServer(s->((LivingEntity)player(s).level().getEntity(parrot)).getHealth());
attack(c,parrot);c.waitFor(m->CarryPanic.active(m.player),100);
server.runOnServer(s->{var p=player(s);check(CarryPanic.active(p),"Real native attack triggers server panic");check(((LivingEntity)p.level().getEntity(parrot)).getHealth()<health,"Ordinary carried animal takes native damage");});
c.getInput().holdKey(o->o.keyUp);c.getInput().holdKey(o->o.keyRight);c.getInput().holdKey(o->o.keyJump);
c.runOnClient(m->{m.player.setYRot(170);m.player.setXRot(15);});c.waitTicks(12);
c.runOnClient(m->{check(m.player.getY()>2.5&&m.player.position().subtract(new Vec3(.5,1,.5)).horizontalDistance()>1,"Actual client moves upward and sideways");
check(CarryPanic.active(m.player),"Panic remains active through steering keys");
var wanted=m.player.getAttached(CarryPanic.FLIGHT).velocity(m.level.getGameTime());
// Level time can advance immediately after the player physics tick. Match either exact tick.
var previous=m.player.getAttached(CarryPanic.FLIGHT).velocity(m.level.getGameTime()-1);
check(Math.min(m.player.getDeltaMovement().distanceTo(wanted),m.player.getDeltaMovement().distanceTo(previous))<.00001,"Movement follows animal, not camera or held keys: actual="+m.player.getDeltaMovement()+", expected="+wanted+", flight="+m.player.getAttached(CarryPanic.FLIGHT)+", time="+m.level.getGameTime());check(m.player.fallDistance<.2,"Flight clears falling distance");
m.options.setCameraType(CameraType.THIRD_PERSON_BACK);});c.waitTicks(2);c.takeScreenshot("carry114-chaotic-flight");
c.getInput().releaseKey(o->o.keyUp);c.getInput().releaseKey(o->o.keyRight);c.getInput().releaseKey(o->o.keyJump);
c.waitTicks(65);c.waitFor(m->!CarryPanic.active(m.player),100);
server.runOnServer(s->{var p=player(s);check(!p.hasAttached(CarryPanic.FLIGHT)&&CarryGlide.supported(p),"Timeout removes panic and retains ordinary glider");check(p.connection.isAcceptingMessages(),"Flight is accepted by server");});
c.runOnClient(m->{check(m.player.getDeltaMovement().y<=0&&m.player.getDeltaMovement().y>=-CarryGlide.DESCENT-.02,"Normal gentle descent resumes");m.options.setCameraType(CameraType.FIRST_PERSON);});
System.out.println("CARRY114_NATIVE_FLIGHT_PASS cursor, real attack, damage, movement, input override, timeout, glide");
server.runOnServer(s->{var p=player(s);p.getRandom().setSeed(114);var bird=p.level().getEntity(parrot);var quadrants=new HashSet<Integer>();
for(int i=0;i<32;i++){
CarryPanic.clear(p);CarryPanic.start(bird);var flight=p.getAttached(CarryPanic.FLIGHT);check(flight.duration()>=40&&flight.duration()<=60,"Duration bounded at 23 seconds");quadrants.add((int)(flight.yaw()/(Math.PI/2)));
for(int t=0;t<=flight.duration();t++){var v=flight.velocity(flight.start()+t);check(v.horizontalDistance()<=.331&&v.y>=0&&v.y<=.241,"Velocity bounded without cumulative impulses");}
}
check(quadrants.size()==4,"Server random headings cover every quadrant");
});c.waitFor(m->CarryPanic.active(m.player),100);
c.getInput().holdKey(o->o.keyShift);c.waitTicks(2);c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);c.getInput().releaseKey(o->o.keyShift);
c.waitFor(m->!CarryService.carrying(m.player)&&!CarryPanic.active(m.player),100);
server.runOnServer(s->{check(!player(s).hasAttached(CarryPanic.FLIGHT),"Native drop clears flight immediately");player(s).level().getEntity(parrot).discard();reset(player(s));});
int cow=server.computeOnServer(s->carry(player(s),EntityTypes.COW).getId());
server.runOnServer(s->{var p=player(s);var animal=(LivingEntity)p.level().getEntity(cow);float before=animal.getHealth();p.attack(animal);
check(CarryService.blocksAttack(p,animal)&&animal.getHealth()==before&&!animal.hurtServer(p.level(),p.damageSources().playerAttack(p),1)&&!CarryPanic.active(p),"Terrestrial carry pair stays protected");CarryPanic.start(animal);check(!CarryPanic.active(p),"Ground animal cannot launch player");CarryService.release(animal,true);animal.discard();reset(p);});
// Every supported appearance can trigger the shared flight, including the gliding chicken.
server.runOnServer(s->{var p=player(s);for(var type:List.of(EntityTypes.CHICKEN,EntityTypes.BAT,EntityTypes.BEE,EntityTypes.ALLAY,EntityTypes.PARROT,EntityTypes.BLAZE,EntityTypes.BREEZE)){
var animal=carry(p,type);check(CarryGlide.flyer(animal)&&CarryPanic.canStrike(p,animal),"Carried flying species can be struck: "+type);
check(animal.hurtServer(p.level(),p.damageSources().playerAttack(p),1)&&CarryPanic.active(p),"Accepted damage launches carrier: "+type);CarryService.release(animal,true);animal.discard();check(!p.hasAttached(CarryPanic.FLIGHT),"Release clears each flight");
}});
int external=server.computeOnServer(s->carry(player(s),EntityTypes.PARROT).getId());
server.runOnServer(s->{var p=player(s);var bird=(LivingEntity)p.level().getEntity(external);var zombie=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);
check(bird.hurtServer(p.level(),p.damageSources().mobAttack(zombie),1)&&CarryPanic.active(p),"External accepted hit also triggers flight");
p.getAbilities().flying=true;CarryGlide.tick(p);check(!p.hasAttached(CarryPanic.FLIGHT),"Creative flying invalidates propulsion");p.getAbilities().flying=false;
CarryPanic.start(bird);bird.hurtServer(p.level(),p.damageSources().mobAttack(zombie),100);CarryGlide.tick(p);check(!CarryPanic.active(p),"Death stops propulsion");CarryService.detach(p);bird.discard();reset(p);});
int ceiling=server.computeOnServer(s->{var p=player(s);var bird=carry(p,EntityTypes.PARROT);for(int x=-3;x<=3;x++)for(int z=-3;z<=3;z++)p.level().setBlockAndUpdate(new BlockPos(x,4,z),Blocks.STONE.defaultBlockState());return bird.getId();});
c.waitFor(m->m.level.getEntity(ceiling)!=null&&CarryService.carried(m.level.getEntity(ceiling)),100);
server.runOnServer(s->{var p=player(s);((LivingEntity)p.level().getEntity(ceiling)).hurtServer(p.level(),p.damageSources().playerAttack(p),1);});c.waitTicks(12);
server.runOnServer(s->{var p=player(s);check(p.getY()<2&&p.level().noBlockCollision(p,p.getBoundingBox().deflate(.02)),"Player cannot fly through low ceiling");check(!CarryService.carrying(p)&&!CarryPanic.active(p),"Blocked carried animal is safely released");
p.level().getEntity(ceiling).discard();for(int x=-3;x<=3;x++)for(int z=-3;z<=3;z++)p.level().removeBlock(new BlockPos(x,4,z),false);reset(p);
var egg=new ItemStack(Items.PARROT_SPAWN_EGG);var size=new CompoundTag();size.putInt("schema",1);size.putInt("per_mille",1000);CustomData.update(DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.inventoryMenu.broadcastChanges();});
c.waitTicks(25);
int familiar=server.computeOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet!=null,"Familiar spawned");pet.setNoAi(true);pet.setPos(p.position().add(0,0,2));check(CarryService.interact(p,pet),"Flying familiar picked up");return pet.getId();});
c.waitFor(m->m.level.getEntity(familiar)!=null&&CarryService.carried(m.level.getEntity(familiar)),100);
float petHealth=server.computeOnServer(s->FamiliarService.active(player(s)).getHealth());attack(c,familiar);c.waitFor(m->CarryPanic.active(m.player),100);
server.runOnServer(s->{var p=player(s);check(FamiliarService.active(p).getHealth()==petHealth&&CarryPanic.active(p),"Friendly familiar tap launches without hurting it");CarryService.detach(p);check(!p.hasAttached(CarryPanic.FLIGHT),"Lifecycle detach clears synchronized flight");});
System.out.println("CARRY114_PASS random flight, native ordinary/familiar strikes, seven species, outside hits, ground protection, drop/death/creative cancellation, ceiling collisions, timeout and normal glide");
}finally{intro.set(false);c.getInput().releaseKey(o->o.keyUp);c.getInput().releaseKey(o->o.keyRight);c.getInput().releaseKey(o->o.keyJump);c.getInput().releaseKey(o->o.keyShift);c.getInput().releaseKey(o->o.keyAttack);c.getInput().releaseKey(o->o.keyUse);}
}
private static void attack(ClientGameTestContext c,int id){
c.waitFor(m->m.level.getEntity(id).position().distanceTo(CarryService.head(m.player,m.level.getEntity(id)))<.01,100);
c.runOnClient(m->{m.options.setCameraType(CameraType.FIRST_PERSON);m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,m.level.getEntity(id).getBoundingBox().getCenter());});c.waitTicks(3);
c.runOnClient(m->check(m.hitResult instanceof EntityHitResult hit&&hit.getEntity().getId()==id,"Native cursor can pick own carried flyer: hit="+m.hitResult+", target="+m.level.getEntity(id).getBoundingBox()+", eye="+m.player.getEyePosition()+", view="+m.player.getLookAngle()+", eligible="+CarryPanic.canStrike(m.player,m.level.getEntity(id))));
c.getInput().holdKey(o->o.keyAttack);c.waitTicks(1);c.getInput().releaseKey(o->o.keyAttack);
}
private static Mob carry(ServerPlayer p,EntityType<? extends Mob> type){var mob=type.create(p.level(),EntitySpawnReason.COMMAND);mob.setNoAi(true);mob.setPos(p.position().add(0,0,2));p.level().addFreshEntity(mob);check(CarryService.interact(p,mob),"Animal picked up: "+type);return mob;}
private static void reset(ServerPlayer p){CarryService.detach(p);p.teleportTo(.5,1,.5);p.setDeltaMovement(Vec3.ZERO);p.resetFallDistance();p.setShiftKeyDown(false);}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,19 @@
package fr.koka.sanctuary.client;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
/** End-to-end builds, personal history, shared catalogue, inline advancements and tap/hold K. */
public final class Catalogue099ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c){
new Plans072ClientChecks().runTest(c);
c.runOnClient(m->{if(PlanHistory.entries().size()!=3)throw new AssertionError("Three genuinely completed plans retained");});
new Metatable096ClientChecks().runTest(c);
try{
var root=net.minecraft.nbt.NbtIo.readCompressed(PlanClient.directory().resolve("recent-completed.nbt"),net.minecraft.nbt.NbtAccounter.create(fr.koka.sanctuary.plans.SchematicIO.MAX_NBT));
var entries=root.getList("Completed").orElseThrow();if(entries.size()!=3)throw new AssertionError("Three completed plans persisted");
for(var entry:entries)fr.koka.sanctuary.plans.SchematicIO.decode((net.minecraft.nbt.CompoundTag)entry,"History");
}catch(Exception e){throw new AssertionError(e);}
System.out.println("CATALOGUE099_PASS: complete creative builds, three-entry persisted history, uniform catalogue, native textured thumbnails, all-block palette, tap/hold K, inline tree and tracking, GUI scales 2/3, FR/EN");
}
}
@@ -0,0 +1,42 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.companion.CompanionService;
import fr.koka.sanctuary.plans.*;
import java.nio.file.*;
import java.util.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.core.BlockPos;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.*;
import net.minecraft.world.level.GameType;
/** Native preview, server result and survival payment for the special plain-clay material. */
final class Clay126Checks {
static void exercise(ClientGameTestContext c,TestServerContext server){
var pos=new BlockPos(4,1,4);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(3,1,3);p.setGameMode(GameType.SURVIVAL);p.getAbilities().invulnerable=true;p.onUpdateAbilities();p.level().setBlockAndUpdate(pos,Statuary.WORKSHOP.defaultBlockState());CompanionService.save(p,CompanionService.record(p).note("seen_mobs","cow"));Statuary.open(p,pos);p.containerMenu.getSlot(0).set(new ItemStack(Items.CLAY,2));p.containerMenu.broadcastChanges();});
c.waitForScreen(ClayWorkshopScreen.class);c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();((ClayWorkshopScreen)m.gui.screen()).load(EntityTypes.COW);});
c.waitFor(m->{var screen=(ClayWorkshopScreen)m.gui.screen();return !screen.busy()&&screen.selected()!=null&&screen.selected().equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem()));},250);
var cow=c.computeOnClient(m->((ClayWorkshopScreen)m.gui.screen()).selected());check(cow.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>3,"Cow source retains texture colors");
for(var material:List.of(Items.OAK_PLANKS,fr.koka.sanctuary.building.ColoredBricks.families().getFirst().clay().asItem(),Items.CLAY)){
server.runOnServer(s->{var menu=s.getPlayerList().getPlayers().getFirst().containerMenu;menu.getSlot(0).set(new ItemStack(material,2));menu.broadcastChanges();});
var expected=c.computeOnClient(m->ClayWorkshopMenu.appearance(cow,material,SculptureMaterialSampler.sample(new ItemStack(material))));
check(material==Items.CLAY?expected.equals(cow):!expected.equals(cow),"Only plain clay retains original colors");
c.waitFor(m->expected.equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())),180);
c.runOnClient(m->check(ItemStack.matches(((ClayWorkshopScreen)m.gui.screen()).preview(),m.player.containerMenu.getSlot(1).getItem()),"Preview matches authoritative material switch"));
}
c.takeScreenshot("clay126-original-cow");
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.PICKUP,m.player));
server.waitFor(s->{var menu=s.getPlayerList().getPlayers().getFirst().containerMenu;return cow.equals(StatuaryEntity.sculpture(menu.getCarried()))&&menu.getSlot(0).getItem().getCount()==1;},100);
c.waitFor(m->cow.equals(StatuaryEntity.sculpture(m.player.containerMenu.getCarried()))&&m.player.containerMenu.getSlot(0).getItem().getCount()==1,100);
Path file=PlanClient.directory().resolve("clay126-"+UUID.randomUUID()+".glb");
try{Files.createDirectories(file.getParent());Files.write(file,Statuary106ClientChecks.fixture());var id=GlbModel.load(file).id();c.runOnClient(m->((ClayWorkshopScreen)m.gui.screen()).load(file));
c.waitFor(m->{var screen=(ClayWorkshopScreen)m.gui.screen();return !screen.busy()&&screen.selected()!=null&&screen.selected().modelId().equals(id)&&screen.selected().equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem()));},250);
c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();check(screen.selected().cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>1,"Imported textured GLB retains its original colors");check(ItemStack.matches(screen.preview(),m.player.containerMenu.getSlot(1).getItem()),"Imported preview equals native result");});
}catch(Exception e){throw new AssertionError(e);}finally{try{Files.deleteIfExists(file);}catch(Exception ignored){}}
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.closeContainer();p.setGameMode(GameType.CREATIVE);p.getAbilities().flying=true;p.onUpdateAbilities();});c.waitFor(m->m.gui.screen()==null,100);
System.out.println("CLAY126_PASS original animal and GLB colors, oak and colored clay palette transfer, switch back, matching previews and one clay paid in survival");
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,47 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.knowledge.*;
import fr.koka.sanctuary.plans.*;
import fr.koka.sanctuary.recipes.*;
import java.util.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.stats.Stats;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
final class Discovery126Checks {
static void exercise(ClientGameTestContext c,TestServerContext server){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var dispatcher=s.getCommands().getDispatcher();
var source=p.createCommandSourceStack();var root=dispatcher.getRoot().getChild("sanctuary").getChild("discovery");
s.getWorldData().setAllowCommands(false);check(!root.canUse(source),"Discovery commands respect native Allow Commands");
try{dispatcher.execute("sanctuary discovery all",source);throw new AssertionError("Denied command executed");}catch(com.mojang.brigadier.exceptions.CommandSyntaxException expected){}
s.getWorldData().setAllowCommands(true);
var before=CompanionService.record(p);var items=new TreeSet<>(RecipeService.knowledge(p).items);int kills=p.getStats().getValue(Stats.ENTITY_KILLED,EntityTypes.WITHER);
command(p,"mob minecraft:wither");check(StatueDiscoveries.known(p).contains("minecraft:wither"),"Precise species unlocks sculpture catalogue");check(CompanionService.record(p).notes().get("seen_mobs").contains("wither"),"Same actual seen_mobs record");check(RecipeService.knowledge(p).items.equals(items),"Mob discovery invents no unrelated item discovery");check(p.getStats().getValue(Stats.ENTITY_KILLED,EntityTypes.WITHER)==kills,"No fake kill stat");
command(p,"block minecraft:pink_glazed_terracotta");var block=BlockKnowledgeService.knowledge(p,Blocks.GLAZED_TERRACOTTA.pick(DyeColor.PINK));check(block.seen()&&!block.possessed()&&block.mined()==0&&block.placed()==0,"Precise block is seen without fabricated possession or stats");check(RecipeService.knowledge(p).items.contains("minecraft:pink_glazed_terracotta")&&RecipeService.knowledge(p).collections.contains("sanctuary:terracotta"),"Block event unlocks its normal recipe collection");
command(p,"item minecraft:amethyst_shard");check(RecipeService.knowledge(p).items.contains("minecraft:amethyst_shard")&&RecipeService.knowledge(p).recipes.contains("minecraft:spyglass"),"Precise item unlocks linked recipes");
String old=CompanionService.record(p).json();check(run(p,"mob minecraft:item")==0,"Nonmob type refused");check(CompanionService.record(p).json().equals(old),"Rejected request does not change discoveries");
try{dispatcher.execute("sanctuary discovery item sanctuary:missing_item",source);throw new AssertionError("Missing ID accepted");}catch(com.mojang.brigadier.exceptions.CommandSyntaxException expected){}
command(p,"mobs all");allSpecies(p);var pos=new BlockPos(4,1,4);Statuary.open(p,pos);check(((ClayWorkshopMenu)p.containerMenu).view.statues().equals(StatueDiscoveries.known(p)),"Workshop sees the shared full catalogue");p.closeContainer();
command(p,"all");all(p);String species=CompanionService.record(p).json(),recipes=RecipeService.knowledge(p).json();var blocks=BlockKnowledgeService.snapshot(p);command(p,"all @s");check(species.equals(CompanionService.record(p).json())&&recipes.equals(RecipeService.knowledge(p).json())&&blocks.equals(BlockKnowledgeService.snapshot(p)),"Repeated all with target selector is idempotent");
check(CompanionService.record(p).paid()==before.paid()&&CompanionService.record(p).unlocked()==before.unlocked(),"Discoveries do not fabricate paid skills");
try{check(dispatcher.execute("sanctuary discovery mob minecraft:cow "+p.getGameProfile().name(),s.createCommandSourceStack())==1,"Console can address an online player");}catch(Exception e){throw new AssertionError(e);}
System.out.println("DISCOVERY126_PASS native permissions, precise block/item/species, linked recipes, shared catalogue, all three categories, all, selector, console, idempotence and unchanged combat/ownership counters");
});
c.waitFor(m->m.gui.screen()==null,100);
}
static void all(ServerPlayer p){
allSpecies(p);check(BlockKnowledgeService.snapshot(p).stream().allMatch(BlockKnowledge::known),"All block discoveries");
var knowledge=RecipeService.knowledge(p);for(var item:BuiltInRegistries.ITEM)if(item!=Items.AIR)check(knowledge.items.contains(BuiltInRegistries.ITEM.getKey(item).toString()),"All registered items discovered");
check(knowledge.recipes.contains("minecraft:spyglass")&&knowledge.recipes.contains("sanctuary:ruby_block"),"Vanilla and Sanctuary recipes are derived from discoveries");
}
static void allSpecies(ServerPlayer p){check(StatueDiscoveries.known(p).size()==BuiltInRegistries.ENTITY_TYPE.stream().filter(t->SpawnEggItem.byId(t).isPresent()).count(),"All registered species discovered");}
static void command(ServerPlayer p,String args){check(run(p,args)==1,"Discovery command succeeds: "+args);}
private static int run(ServerPlayer p,String args){try{return p.level().getServer().getCommands().getDispatcher().execute("sanctuary discovery "+args,p.createCommandSourceStack());}catch(Exception e){throw new AssertionError(e);}}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,134 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.companion.CompanionType;
import fr.koka.sanctuary.eggs.BirthEggs;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.progression.SanctuaryGameplay;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.dispenser.BlockSource;
import net.minecraft.core.registries.*;
import net.minecraft.nbt.NbtOps;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.*;
import net.minecraft.world.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.animal.Animal;
import net.minecraft.world.entity.animal.sheep.Sheep;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.*;
import net.minecraft.world.item.context.UseOnContext;
import net.minecraft.world.item.enchantment.Enchantments;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.phys.*;
public final class Eggs107ClientChecks implements FabricClientGameTest {
private static final AABB AREA = new AABB(-20,70,-20,20,100,20);
public void runTest(ClientGameTestContext c) {
var intro = new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());
}});
try {
for(boolean enabled:new boolean[]{true,false}) try(var world=c.worldBuilder().adjustSettings(s->{
s.setName("Eggs107 "+enabled+" "+UUID.randomUUID());s.setSeed("107");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());
s.getGameRules().set(SanctuaryGameplay.ENABLED,enabled,null);
}).create()) {
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&(!enabled||ProgressionClient.active()&&!IntroSpawnFlash.active()),1200);
world.getServer().runOnServer(server->{
var p=server.getPlayerList().getPlayers().getFirst();var l=p.level();
check(BirthEggs.active(l)==enabled,"Gameplay scope "+enabled);
for(int x=-15;x<=15;x++)for(int z=-15;z<=15;z++)l.setBlockAndUpdate(new BlockPos(x,79,z),Blocks.GRASS_BLOCK.defaultBlockState());
p.teleportTo(0,80,-4);p.getInventory().clearContent();p.setGameMode(GameType.SURVIVAL);
if(enabled) { ordinaryBirths(l,p); specialBirths(l,p); spawners(l,p,true); }
else { vanillaBirth(l);spawners(l,p,false); }
});
}
System.out.println("EGGS107_NATIVE_PASS: births, variants, crosses, ownership, hatch/use/dispenser, special births, spawners, vanilla scope");
} finally { intro.set(false); }
}
private static void ordinaryBirths(ServerLevel l,ServerPlayer p) {
// Exercise every common Animal breeding path and native child constructor.
String[] breeders={"armadillo","axolotl","bee","camel","cat","chicken","cow","donkey","fox","goat","hoglin","horse","llama","mooshroom","nautilus","ocelot","panda","pig","rabbit","sheep","strider","wolf"};
for(String id:breeders){
clear(l);var type=BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.withDefaultNamespace(id));
var a=(Animal)spawn(l,type);var b=(Animal)spawn(l,type);a.setInLove(p);b.setInLove(p);
a.spawnChildFromBreeding(l,b);
ItemStack egg=oneEgg(l);check(SpawnEggItem.getType(egg)==type,"Native species "+id);
check(l.getEntitiesOfClass(Mob.class,AREA).size()==2,"No simultaneous live child "+id);
check(a.getAge()==6000&&b.getAge()==6000&&!a.isInLove()&&!b.isInLove(),"Cooldown/love "+id);
var payload=egg.get(DataComponents.ENTITY_DATA).copyTagWithoutId();check(payload.getIntOr("Age",0)<0,"Baby payload "+id);
check(!payload.contains("UUID")&&!payload.contains("Pos")&&!payload.contains("Brain"),"Portable data "+id);
}
clear(l);var a=(Animal)spawn(l,EntityTypes.HORSE);var b=(Animal)spawn(l,EntityTypes.DONKEY);a.spawnChildFromBreeding(l,b);
check(SpawnEggItem.getType(oneEgg(l))==EntityTypes.MULE,"Horse/donkey gives mule");
clear(l);var red=(Sheep)spawn(l,EntityTypes.SHEEP);var blue=(Sheep)spawn(l,EntityTypes.SHEEP);red.setColor(DyeColor.RED);blue.setColor(DyeColor.BLUE);red.spawnChildFromBreeding(l,blue);
var egg=roundTrip(l,oneEgg(l));check(egg.get(DataComponents.LORE)!=null,"Birth tooltip persisted");
var sheep=(Sheep)hatch(l,p,egg.copy(),EntityTypes.SHEEP);check(sheep.isBaby()&&sheep.getColor()==DyeColor.PURPLE,"Native colour inheritance and guaranteed baby");
var used=egg.copy();var onAdult=SpawnEggItem.spawnOffspringFromSpawnEgg(p,red,EntityTypes.SHEEP,l,new Vec3(7,80,7),used).orElseThrow();
check(((Sheep)onAdult).getColor()==DyeColor.PURPLE&&onAdult.isBaby()&&used.isEmpty(),"Use on adult preserves actual birth");
var dispenserPos=new BlockPos(10,80,10);var state=Blocks.DISPENSER.defaultBlockState().setValue(DispenserBlock.FACING,Direction.WEST);l.setBlockAndUpdate(dispenserPos,state);
var dispenser=(DispenserBlockEntity)l.getBlockEntity(dispenserPos);
dispenser.setItem(0,egg.copy());
try{var method=DispenserBlock.class.getDeclaredMethod("dispenseFrom",ServerLevel.class,net.minecraft.world.level.block.state.BlockState.class,BlockPos.class);method.setAccessible(true);method.invoke(Blocks.DISPENSER,l,state,dispenserPos);}catch(Exception e){throw new RuntimeException(e);}
check(dispenser.getItem(0).isEmpty(),"Dispenser spends egg");
check(l.getEntitiesOfClass(Sheep.class,new AABB(8,79,9,10,82,12)).stream().anyMatch(s->s.isBaby()&&s.getColor()==DyeColor.PURPLE),"Dispenser baby/variant");
clear(l);var wolf=(net.minecraft.world.entity.animal.wolf.Wolf)spawn(l,EntityTypes.WOLF);wolf.tame(p);var other=(Animal)spawn(l,EntityTypes.WOLF);wolf.spawnChildFromBreeding(l,other);
var puppy=(net.minecraft.world.entity.animal.wolf.Wolf)hatch(l,p,oneEgg(l).copy(),EntityTypes.WOLF);check(puppy.isBaby()&&puppy.isTame()&&puppy.isOwnedBy(p),"Tamed wolf inherits owner");
for(var species:CompanionType.values())check(SpawnEggItem.byId(BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.withDefaultNamespace(species.id()))).isPresent(),"Catalogue native egg "+species.id());
}
private static void specialBirths(ServerLevel l,ServerPlayer p) {
clear(l);var frog=(Animal)spawn(l,EntityTypes.FROG);var mate=(Animal)spawn(l,EntityTypes.FROG);frog.spawnChildFromBreeding(l,mate);
check(SpawnEggItem.getType(oneEgg(l))==EntityTypes.TADPOLE,"Frog tadpole egg");
check(!frog.getBrain().hasMemoryValue(net.minecraft.world.entity.ai.memory.MemoryModuleType.IS_PREGNANT),"No second frogspawn");
clear(l);var sniffer=(Animal)spawn(l,EntityTypes.SNIFFER);sniffer.spawnChildFromBreeding(l,(Animal)spawn(l,EntityTypes.SNIFFER));
check(SpawnEggItem.getType(oneEgg(l))==EntityTypes.SNIFFER,"Sniffer replaces native block egg");check(hatch(l,p,oneEgg(l).copy(),EntityTypes.SNIFFER).isBaby(),"Snifflet");
clear(l);var turtle=(net.minecraft.world.entity.animal.turtle.Turtle)spawn(l,EntityTypes.TURTLE);var partner=(Animal)spawn(l,EntityTypes.TURTLE);
try {var cls=Class.forName("net.minecraft.world.entity.animal.turtle.Turtle$TurtleBreedGoal");var ctor=cls.getDeclaredConstructor(net.minecraft.world.entity.animal.turtle.Turtle.class,double.class);ctor.setAccessible(true);var goal=ctor.newInstance(turtle,1.0);var field=net.minecraft.world.entity.ai.goal.BreedGoal.class.getDeclaredField("partner");field.setAccessible(true);field.set(goal,partner);var breed=cls.getDeclaredMethod("breed");breed.setAccessible(true);breed.invoke(goal);}catch(Exception e){throw new RuntimeException(e);}
check(!turtle.hasEgg()&&turtle.getAge()==6000&&partner.getAge()==6000,"Turtle no double laying");check(SpawnEggItem.getType(oneEgg(l))==EntityTypes.TURTLE,"Turtle egg");
clear(l);var allay=spawn(l,EntityTypes.ALLAY);invoke(allay,"duplicateAllay");var allayEgg=oneEgg(l);check(SpawnEggItem.getType(allayEgg)==EntityTypes.ALLAY,"Allay egg");check(allayEgg.get(DataComponents.ENTITY_DATA).copyTagWithoutId().getLongOr("DuplicationCooldown",0)==6000,"Allay cooldown stored");check(l.getEntitiesOfClass(Mob.class,AREA).size()==1,"No duplicate allay entity");
clear(l);var villager=(net.minecraft.world.entity.npc.villager.Villager)spawn(l,EntityTypes.VILLAGER);var other=(net.minecraft.world.entity.npc.villager.Villager)spawn(l,EntityTypes.VILLAGER);
try{var cls=net.minecraft.world.entity.ai.behavior.VillagerMakeLove.class;var method=cls.getDeclaredMethod("breed",ServerLevel.class,villager.getClass(),villager.getClass());method.setAccessible(true);var result=(Optional<?>)method.invoke(new net.minecraft.world.entity.ai.behavior.VillagerMakeLove(),l,villager,other);check(result.isEmpty(),"Caller releases reserved bed");}catch(Exception e){throw new RuntimeException(e);}
check(SpawnEggItem.getType(oneEgg(l))==EntityTypes.VILLAGER&&villager.getAge()==6000&&other.getAge()==6000,"Villager birth");
check(hatch(l,p,oneEgg(l).copy(),EntityTypes.VILLAGER).isBaby(),"Baby villager");
}
private static void spawners(ServerLevel l,ServerPlayer p,boolean enabled) {
for(String mode:List.of("normal","silk","fortune","hand","creative","blockDropsOff")) {
clear(l);p.setGameMode(mode.equals("creative")?GameType.CREATIVE:GameType.SURVIVAL);var tool=mode.equals("hand")?ItemStack.EMPTY:new ItemStack(Items.DIAMOND_PICKAXE);
if(mode.equals("silk")||mode.equals("fortune"))tool.enchant(l.registryAccess().lookupOrThrow(Registries.ENCHANTMENT).getOrThrow(mode.equals("silk")?Enchantments.SILK_TOUCH:Enchantments.FORTUNE),mode.equals("silk")?1:3);
p.setItemInHand(InteractionHand.MAIN_HAND,tool);var pos=new BlockPos(1,80,1);l.setBlockAndUpdate(pos,Blocks.SPAWNER.defaultBlockState());
((SpawnerBlockEntity)l.getBlockEntity(pos)).getSpawner().setEntityId(EntityTypes.BLAZE,l,l.getRandom(),pos);
var rule=net.minecraft.world.level.gamerules.GameRules.BLOCK_DROPS;l.getGameRules().set(rule,!mode.equals("blockDropsOff"),l.getServer());
check(p.gameMode.destroyBlock(pos),"Actual spawner break "+mode);
int count=l.getEntitiesOfClass(ItemEntity.class,AREA).stream().filter(e->e.getItem().is(Items.BLAZE_SPAWN_EGG)).mapToInt(e->e.getItem().getCount()).sum();
check(count==(enabled&&(mode.equals("normal")||mode.equals("fortune"))?1:0),"Spawner egg count "+mode+" enabled="+enabled+" count="+count);
l.getGameRules().set(rule,true,l.getServer());
}
p.setGameMode(GameType.CREATIVE);
}
private static void vanillaBirth(ServerLevel l) {clear(l);var a=(Animal)spawn(l,EntityTypes.COW);a.spawnChildFromBreeding(l,(Animal)spawn(l,EntityTypes.COW));check(l.getEntitiesOfClass(Animal.class,AREA).size()==3&&l.getEntitiesOfClass(ItemEntity.class,AREA).isEmpty(),"Vanilla birth unchanged");}
private static ItemStack roundTrip(ServerLevel l,ItemStack egg){var ops=l.registryAccess().createSerializationContext(NbtOps.INSTANCE);return ItemStack.CODEC.parse(ops,ItemStack.CODEC.encodeStart(ops,egg).getOrThrow()).getOrThrow();}
private static Mob hatch(ServerLevel l,ServerPlayer p,ItemStack egg,EntityType<?> type){var before=new HashSet<UUID>();for(var e:l.getEntitiesOfClass(Mob.class,AREA))before.add(e.getUUID());p.setItemInHand(InteractionHand.MAIN_HAND,egg);var at=new BlockPos(6,79,6);var result=egg.useOn(new UseOnContext(p,InteractionHand.MAIN_HAND,new BlockHitResult(new Vec3(6.5,80,6.5),Direction.UP,at,false)));check(result.consumesAction()&&egg.isEmpty(),"Survival hatch consumes once");return l.getEntitiesOfClass(Mob.class,AREA).stream().filter(m->m.getType()==type&&!before.contains(m.getUUID())).findFirst().orElseThrow();}
private static Entity spawn(ServerLevel l,EntityType<?> type){var e=type.create(l,EntitySpawnReason.BREEDING);e.snapTo(0,80,0,0,0);if(e instanceof Mob mob)mob.finalizeSpawn(l,l.getCurrentDifficultyAt(e.blockPosition()),EntitySpawnReason.BREEDING,null);if(e instanceof AgeableMob ageable)ageable.setAge(0);l.addFreshEntity(e);return e;}
private static ItemStack oneEgg(ServerLevel l){var eggs=l.getEntitiesOfClass(ItemEntity.class,AREA);check(eggs.size()==1&&eggs.getFirst().getItem().getCount()==1,"Exactly one dropped egg "+eggs);return eggs.getFirst().getItem();}
private static void clear(ServerLevel l){for(var e:l.getEntities(null,AREA))if(!(e instanceof net.minecraft.world.entity.player.Player))e.discard();}
private static void invoke(Object o,String name){try{var m=o.getClass().getDeclaredMethod(name);m.setAccessible(true);m.invoke(o);}catch(Exception e){throw new RuntimeException(e);}}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,123 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.companion.CompanionService;
import fr.koka.sanctuary.companion.CompanionWorld;
import net.minecraft.client.KeyMapping;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.CustomData;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.Vec3;
/** Server discovery snapshots, restricted catalogue, and actual idle movement plus combat regression. */
public final class Exploration100ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Exploration100 "+UUID.randomUUID());s.setSeed("100");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
var table=new BlockPos(3,1,3);
c.runOnClient(m->imports());
server.runOnServer(s->{var p=player(s);p.teleportTo(.5,1,.5);p.getInventory().clearContent();
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set night");
for(int x=-20;x<=20;x++)for(int z=-20;z<=20;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
CompanionService.save(p,CompanionService.record(p).note("seen_mobs",""));
for(var q:fr.koka.sanctuary.multiblock.Metatable.members(table))p.level().setBlockAndUpdate(q,Blocks.CRAFTING_TABLE.defaultBlockState());
check(fr.koka.sanctuary.multiblock.Metatable.assemble(p,table),"Assembled workshop");
// Owning an egg is not an encounter.
p.getInventory().setItem(0,new ItemStack(Items.PIG_SPAWN_EGG));
check(!fr.koka.sanctuary.plans.StatueDiscoveries.known(p).contains("minecraft:pig"),"Egg does not unlock statue");
var cow=EntityTypes.COW.create(p.level(),EntitySpawnReason.COMMAND);cow.setNoAi(true);cow.setPos(.5,1,4.5);p.level().addFreshEntity(cow);
p.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,cow.getBoundingBox().getCenter());
fr.koka.sanctuary.companion.CompanionQueries.observeSpecies(p);
check(fr.koka.sanctuary.plans.StatueDiscoveries.known(p).contains("minecraft:cow"),"Native aimed encounter unlocks cow");cow.discard();
p.awardStat(net.minecraft.stats.Stats.ENTITY_KILLED.get(EntityTypes.ZOMBIE));
p.awardStat(net.minecraft.stats.Stats.ENTITY_KILLED_BY.get(EntityTypes.SKELETON));
check(fr.koka.sanctuary.plans.StatueDiscoveries.known(p).containsAll(List.of("minecraft:cow","minecraft:zombie","minecraft:skeleton")),"All three discovery sources");
});
c.waitFor(m->fr.koka.sanctuary.multiblock.Metatable.state(m.level,table,true)==1,100);
server.runOnServer(s->fr.koka.sanctuary.multiblock.Metatable.open(player(s),table));c.waitForScreen(PlansScreen.class);
c.runOnClient(m->{check(MetatableClient.knows(EntityTypes.COW)&&MetatableClient.knows(EntityTypes.ZOMBIE)&&MetatableClient.knows(EntityTypes.SKELETON),"Server snapshot reaches client");check(!MetatableClient.knows(EntityTypes.PIG),"Unknown species hidden even creative");
var before=PlanClient.plan;PlanClient.generate(EntityTypes.PIG.create(m.level,EntitySpawnReason.COMMAND),8,"mixed");check(!PlanClient.busy&&PlanClient.plan==before,"Unknown identifier cannot start generator");
m.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.plans.tab.statue")).findFirst().orElseThrow().onPress(enter());
});c.takeScreenshot("exploration100-discovered-statues");
c.runOnClient(m->{m.gui.screen().onClose();check(!MetatableClient.knows(EntityTypes.COW),"Session discovery snapshot cleared");});
for(var item:List.of(Items.WOLF_SPAWN_EGG,Items.CHICKEN_SPAWN_EGG,Items.SLIME_SPAWN_EGG,Items.BAT_SPAWN_EGG)){
for(String mood:List.of("calm","curious","playful")){
var start=new AtomicReference<Vec3>();var distinct=new HashSet<BlockPos>();var moved=new AtomicBoolean();var paused=new AtomicInteger();
server.runOnServer(s->{var p=player(s);p.teleportTo(.5,1,.5);equip(p,item,mood.equals("calm")?FamiliarPersonality.PROTECTIVE:FamiliarPersonality.PACIFIST,mood,1000);var pet=FamiliarService.active(p);pet.setPos(.5,pet.motion().flies()?2.6:1,1.5);pet.stopRoaming();start.set(pet.position());});
for(int tick=0;tick<28;tick++){
c.waitTicks(10);server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet!=null,"Pet remains active");
distinct.add(pet.blockPosition());if(pet.position().distanceToSqr(start.get())>1)moved.set(true);
if(pet.getNavigation().isDone())paused.incrementAndGet();
check(pet.distanceToSqr(p)<100,"Idle stays near owner");check(FamiliarBattle.target(p)==null,"No unsolicited target");
});
}
check(moved.get()&&distinct.size()>=3,"Real wandering: "+item+" "+mood+" cells="+distinct);check(paused.get()>0,"Idle has pauses: "+item+" "+mood);
System.out.println("ROAM100_PASS "+item+" "+mood+" cells="+distinct.size()+" pauses="+paused);
}
}
}
new Autonomy074ClientChecks().runTest(c);
System.out.println("EXPLORATION100_PASS");
}
private static void imports(){
for(int version:List.of(2,3)){
var root=new CompoundTag();root.putInt("Version",version);root.putInt("DataVersion",2730);root.putShort("Width",(short)1);root.putShort("Height",(short)1);root.putShort("Length",(short)1);
var blocks=version==3?new CompoundTag():root;if(version==3)root.put("Blocks",blocks);
var palette=new CompoundTag();palette.putInt("minecraft:grass",0);blocks.put("Palette",palette);blocks.putByteArray(version==3?"Data":"BlockData",new byte[]{0});
var file=PlanClient.directory().resolve("beta100-fixture-"+version+".schem");
try{
java.nio.file.Files.createDirectories(file.getParent());net.minecraft.nbt.NbtIo.writeCompressed(version==3?wrap(root):root,file);
var before=java.nio.file.Files.readAllBytes(file);
var plan=fr.koka.sanctuary.plans.SchematicIO.read(file);
check(plan.cells().getFirst().state().is(Blocks.SHORT_GRASS),"Native old grass rename: Sponge "+version);
check(Arrays.equals(before,java.nio.file.Files.readAllBytes(file)),"Import preserves original bytes");
palette.remove("minecraft:grass");palette.putInt("missing_mod:unknown",0);
try{fr.koka.sanctuary.plans.SchematicIO.decode(version==3?wrap(root):root,"Bad");throw new AssertionError("Missing mod rejected");}
catch(java.io.IOException expected){check(expected.getMessage().contains("missing_mod:unknown"),"Useful unknown block diagnostic");}
}catch(java.io.IOException e){throw new AssertionError(e);}
finally{try{java.nio.file.Files.deleteIfExists(file);}catch(java.io.IOException ignored){}}
}
System.out.println("IMPORT100_PASS Sponge v2/v3, native data fixes, missing block diagnosis, original bytes preserved");
}
private static CompoundTag wrap(CompoundTag root){var outer=new CompoundTag();outer.put("Schematic",root);return outer;}
private static void equip(ServerPlayer p,Item item,FamiliarPersonality personality,String mood,int size){
p.stopRiding();CarryService.detach(p);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);p.removeAttached(FamiliarBattle.COMBAT_LOCK);p.removeAttached(FamiliarBattle.LAST_FAMILIAR);
var egg=new ItemStack(item);var scale=new CompoundTag();scale.putInt("schema",1);scale.putInt("per_mille",size);CustomData.update(DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,scale));
var profile=FamiliarBattle.world(p.level().getServer()).catalog.forEgg(egg);UUID id;do{id=UUID.randomUUID();}while(FamiliarPersonality.of(id,profile.id())!=personality||!FamiliarBond.create(id,profile,0,"test",p.getUUID()).temperament().equals(mood));
FamiliarBattle.world(p.level().getServer()).store.put(FamiliarBond.create(id,profile,0,"test",p.getUUID()));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,FamiliarIdentity.assign(egg,id));
CompanionService.tick(p);p.tickCount+=10-p.tickCount%10;FamiliarService.tick(p);check(FamiliarService.active(p)!=null,"Familiar spawned: "+item);p.inventoryMenu.broadcastChanges();
}
private static Mob enemy(ServerPlayer p,double x,double y,double z){var e=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);e.setNoAi(true);e.getAttribute(Attributes.MAX_HEALTH).setBaseValue(100);e.setHealth(100);e.setPos(x,y,z);p.level().addFreshEntity(e);return e;}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -67,8 +67,20 @@ public final class Fourneau085ClientChecks implements FabricClientGameTest {
c.waitFor(m->FourneauModels.modelAt(ORIGIN)!=null&&FourneauModels.modelAt(ORIGIN).getPath().contains(facing.getName()),200);
c.waitTicks(8);c.getInput().lookAt(ORIGIN.offset(1,1,1));c.waitTicks(15);
c.runOnClient(m->{for(int x=0;x<3;x++)for(int y=0;y<3;y++)for(int z=0;z<3;z++)check(FourneauModels.modelAt(ORIGIN.offset(x,y,z))!=null,"All 27 components synchronized across four chunks");});
c.takeScreenshot("fourneau085-"+facing.getName());
server.runOnServer(s->{var l=s.getPlayerList().getPlayers().getFirst().level();
var f=(FurnaceBlockEntity)l.getBlockEntity(ORIGIN.offset(1,1,1));
f.setItem(0,new ItemStack(Items.RAW_IRON,64));f.setItem(1,new ItemStack(Items.COAL,64));});
c.waitFor(m->oven.members().stream().allMatch(p->m.level.getBlockState(p).getValue(AbstractFurnaceBlock.LIT)),200);
c.runOnClient(m->verifyFire(m,facing,true));
c.waitTicks(15);c.takeScreenshot("fourneau094-lit-"+facing.getName());
}
server.runOnServer(s->{var l=s.getPlayerList().getPlayers().getFirst().level();
for(var pos:oven.members()) {var f=(FurnaceBlockEntity)l.getBlockEntity(pos);
f.setItem(0,ItemStack.EMPTY);f.setItem(1,ItemStack.EMPTY);((MultiblockFurnaceAccess)f).sanctuary$heat(0);}
((FurnaceBlockEntity)l.getBlockEntity(ORIGIN)).setItem(0,new ItemStack(Items.DIAMOND,17));});
c.waitFor(m->oven.members().stream().noneMatch(p->m.level.getBlockState(p).getValue(AbstractFurnaceBlock.LIT)),200);
c.runOnClient(m->verifyFire(m,Direction.WEST,false));
c.waitTicks(10);c.takeScreenshot("fourneau094-cold");
// Resource reload must retain the synchronized appearance and rebuild native models.
var reload=c.computeOnClient(m->m.reloadResourcePacks());c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(10);
c.takeScreenshot("fourneau085-reload");
@@ -92,9 +104,41 @@ public final class Fourneau085ClientChecks implements FabricClientGameTest {
c.getInput().lookAt(ORIGIN.offset(1,1,1));c.waitTicks(15);c.takeScreenshot("fourneau085-return");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.level().destroyBlock(ORIGIN.offset(2,2,2),true);});
c.waitFor(m->FourneauModels.modelAt(ORIGIN)==null,200);c.waitTicks(10);c.takeScreenshot("fourneau085-broken");
System.out.println("FOURNEAU085_PASS: four orientations, four chunks, native models, resource reload, unload/re-entry, stock preservation, dismantle and break");
System.out.println("FOURNEAU094_PASS: lit and cold facade, particles inside textured apertures, vanilla furnace preserved; FOURNEAU085_PASS: four orientations, four chunks, native models, resource reload, unload/re-entry, stock preservation, dismantle and break");
}
}
private static void verifyFire(net.minecraft.client.Minecraft m,Direction facing,boolean lit){
var emissions=fr.koka.sanctuary.gametest.Fourneau094Probe.emissions;
emissions.clear();fr.koka.sanctuary.gametest.Fourneau094Probe.armed=true;
try {
var random=net.minecraft.util.RandomSource.create(94);
// Real block animation entry point must replace all 27 individual furnace sources.
for(int n=0;n<50;n++)for(var pos:oven.members()){
var state=m.level.getBlockState(pos);
check(FourneauModels.modelAt(pos,state).getPath().endsWith("_lit")==lit,"Whole facade follows shared heat");
state.getBlock().animateTick(state,m.level,pos,random);
}
check(emissions.size()==(lit?200:0),"Exactly two flame/smoke pairs per machine, none when cold: "+emissions.size());
var texture=javax.imageio.ImageIO.read(Fourneau085ClientChecks.class.getResourceAsStream("/assets/sanctuary/textures/block/fourneau/front_on.png"));
var original=javax.imageio.ImageIO.read(Fourneau085ClientChecks.class.getResourceAsStream("/assets/sanctuary/textures/block/fourneau/front.png"));
for(var emission:emissions){
var q=emission.point().subtract(ORIGIN.getX(),ORIGIN.getY(),ORIGIN.getZ());
double u=switch(facing){case NORTH->3-q.x;case SOUTH->q.x;case EAST->3-q.z;case WEST->q.z;default->throw new AssertionError();};
double normal=switch(facing){case NORTH->-q.z;case SOUTH->q.z-3;case EAST->q.x-3;case WEST->-q.x;default->0;};
check(Math.abs(normal-.02)<.00001,"Emission is in front of facade, never a side or interior block");
int x=(int)(u*16),y=(int)((3-q.y)*16);
int rgb=texture.getRGB(x,y),old=original.getRGB(x,y);int r=(rgb>>16)&255,g=(rgb>>8)&255,b=rgb&255;
check(r>100&&r>b*1.5,"Emission overlaps fire texel "+x+","+y);
check((old&255)<80&&((old>>8)&255)<80&&((old>>16)&255)<80,"Emission overlaps original dark opening");
check(y==16||y==37,"Both correct aperture heights");
}
// A non-assembled furnace must keep the native animation.
emissions.clear();var pos=ORIGIN.offset(7,0,0);
Blocks.FURNACE.defaultBlockState().setValue(AbstractFurnaceBlock.LIT,true).getBlock().animateTick(Blocks.FURNACE.defaultBlockState().setValue(AbstractFurnaceBlock.LIT,true),m.level,pos,random);
check(emissions.size()==2,"Unassembled furnace retains native particles");
}catch(java.io.IOException e){throw new AssertionError(e);}
finally{fr.koka.sanctuary.gametest.Fourneau094Probe.armed=false;emissions.clear();}
}
private static net.minecraft.client.input.KeyEvent enter(){return new net.minecraft.client.input.KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,103 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import fr.koka.sanctuary.mixin.MultiblockFurnaceAccess;
import fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.*;
/** Native quick-transfer, arbitrary slot placement, shared cooking and synchronized feedback. */
public final class Furnace115ClientChecks implements FabricClientGameTest {
private static final BlockPos ORIGIN=new BlockPos(2,1,2);
private static MultiblockData.Machine oven;
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Furnace115 "+UUID.randomUUID());s.setSeed("115");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.onUpdateAbilities();p.getInventory().clearContent();p.teleportTo(1.5,1,1.5);
for(int x=-2;x<8;x++)for(int z=-2;z<8;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
oven=new MultiblockData.Machine(UUID.randomUUID(),ORIGIN,true,Direction.NORTH);for(var pos:oven.members())p.level().setBlockAndUpdate(pos,Blocks.FURNACE.defaultBlockState());check(Multiblocks.assemble(p,oven),"Assemble");
for(int i=0;i<9;i++){var stack=new ItemStack(Items.RAW_IRON,8);stack.set(DataComponents.CUSTOM_NAME,Component.literal("Batch "+i));p.getInventory().setItem(i,stack);}Multiblocks.open(p,oven,ORIGIN,0,"",0);
});
c.waitFor(m->m.gui.screen() instanceof MultiblockScreen&&m.player.containerMenu.getSlot(54).hasItem(),200);
// One native Shift-drag crosses nine hotbar stacks, with no mouse release between them.
c.getInput().holdKey(o->o.keyShift);move(c,54);c.runOnClient(m->m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,InputConstants.MOD_SHIFT),1));
for(int i=55;i<63;i++)move(c,i);
c.runOnClient(m->m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,InputConstants.MOD_SHIFT),0));c.getInput().releaseKey(o->o.keyShift);
c.waitTicks(8);c.takeScreenshot("furnace115-inputs");
c.waitFor(m->m.player.containerMenu.getSlot(8).hasItem()&&!m.player.containerMenu.getSlot(62).hasItem(),150);
c.runOnClient(m->{for(int i=0;i<9;i++)check(m.player.containerMenu.getSlot(i).getItem().getHoverName().getString().equals("Batch "+i),"Shift-drag fills consecutive UI slots, no three-column gaps");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);for(int i=0;i<9;i++)check(stock.getItem(i*3).getCount()==8,"Physical inputs correspond to visible slots");});
// Move an ingredient to a distant, arbitrary slot using real inventory packets.
click(c,1,0,ContainerInput.PICKUP);click(c,23,0,ContainerInput.PICKUP);c.waitTicks(10);
c.runOnClient(m->{check(m.player.containerMenu.getSlot(1).getItem().isEmpty()&&m.player.containerMenu.getSlot(23).getItem().getCount()==8,"Manual placement remains in chosen cell: "+m.player.containerMenu.slots.stream().filter(slot->slot.hasItem()).map(slot->slot.index+":"+slot.getItem()).toList()+", cursor="+m.player.containerMenu.getCarried());});
click(c,23,0,ContainerInput.PICKUP);
click(c,-999,net.minecraft.world.inventory.AbstractContainerMenu.getQuickcraftMask(0,0),ContainerInput.QUICK_CRAFT);
for(int i:new int[]{1,9,10,13,14,22})click(c,i,net.minecraft.world.inventory.AbstractContainerMenu.getQuickcraftMask(1,0),ContainerInput.QUICK_CRAFT);
click(c,-999,net.minecraft.world.inventory.AbstractContainerMenu.getQuickcraftMask(2,0),ContainerInput.QUICK_CRAFT);click(c,23,0,ContainerInput.PICKUP);c.waitTicks(6);
c.runOnClient(m->{for(int i:new int[]{1,9,10,13,14,22})check(m.player.containerMenu.getSlot(i).getItem().getCount()==1,"Native drag distribution keeps every chosen column");check(m.player.containerMenu.getSlot(23).getItem().getCount()==2&&m.player.containerMenu.getCarried().isEmpty(),"Distributed count conserved");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);check(stock.getItem(69).getCount()==2&&stock.getItem(3).getCount()==1,"Manual distribution matches server");for(int i=0;i<27;i++)check(p.containerMenu.getSlot(i).mayPlace(new ItemStack(Items.RAW_IRON)),"Every visible input validates its physical input slot, not Slot.index");reset(stock);stock.setItem(0,new ItemStack(Items.RAW_IRON,4));stock.setItem(18,new ItemStack(Items.RAW_GOLD,4));
var coal=new ItemStack(Items.COAL,5);coal.set(DataComponents.CUSTOM_NAME,Component.literal("Stationary fuel"));stock.setItem(79,coal);
for(int tick=0;tick<205;tick++)MultiblockCooking.tick(p.level(),oven);
check(stock.getItem(79).getCount()==4&&stock.getItem(79).getHoverName().getString().equals("Stationary fuel"),"Named unburnt fuel stays in original cell");
for(int i=1;i<79;i+=3)check(stock.getItem(i).isEmpty(),"No displaced coal in other slots");
check(stock.getItem(2).is(Items.IRON_INGOT)&&stock.getItem(20).is(Items.GOLD_INGOT),"Shared heat cooks both arbitrary lanes");
Multiblocks.open(p,oven,ORIGIN,0,"",0);
});
c.waitTicks(6);c.runOnClient(m->{var menu=(MultiblockMenu)m.player.containerMenu;check(menu.cooking==2&&menu.heat>0&&menu.laneState(6)==MultiblockMenu.COOKING,"Real per-lane feedback synchronizes");});c.takeScreenshot("furnace115-cooking");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);reset(stock);
stock.setItem(0,new ItemStack(Items.RAW_IRON));stock.setItem(3,new ItemStack(Items.DIAMOND));stock.setItem(6,new ItemStack(Items.RAW_GOLD));stock.setItem(8,new ItemStack(Items.GOLD_INGOT,64));
var a=(MultiblockFurnaceAccess)stock.cell(0);a.sanctuary$progress(80);a.sanctuary$total(200);p.containerMenu.broadcastChanges();
});c.waitTicks(6);
c.runOnClient(m->{var menu=(MultiblockMenu)m.player.containerMenu;check(menu.laneState(0)==MultiblockMenu.NO_FUEL&&menu.laneProgress(0)==40,"Paused progress is visible and preserved");check(menu.laneState(1)==MultiblockMenu.NO_RECIPE,"Invalid recipe explained");check(menu.laneState(2)==MultiblockMenu.BLOCKED,"Blocked output explained");check(menu.cooking==0&&menu.heat==0,"Cold furnace clearly shown");});c.takeScreenshot("furnace115-paused");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();cookingRegressions(p);var stock=new MultiblockContainer(p.level(),oven);reset(stock);
for(int i=0;i<9;i++){var stack=new ItemStack(Items.COAL,5);stack.set(DataComponents.CUSTOM_NAME,Component.literal("Fuel "+i));p.getInventory().setItem(i,stack);}Multiblocks.open(p,oven,ORIGIN,0,"",1);
});c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.section()==1&&menu.getSlot(54).hasItem(),100);
// No cooking in this fixture: compare the predicted UI in the same call, before any server reply.
c.runOnClient(m->{var menu=m.player.containerMenu;for(int i=0;i<9;i++){
m.gameMode.handleContainerInput(menu.containerId,54+i,0,ContainerInput.QUICK_MOVE,m.player);
check(menu.getSlot(54+i).getItem().isEmpty()&&menu.getSlot(i).getItem().getCount()==5,"Immediate Shift prediction uses consecutive fuel cells");}
check(!menu.getSlot(9).mayPlace(new ItemStack(Items.DIAMOND)),"Client rejects invalid fuel like server");});
c.waitTicks(8);server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);for(int i=0;i<9;i++)check(stock.getItem(i*3+1).getCount()==5,"Fuel server mapping matches prediction");for(int i=0;i<27;i++){check(p.containerMenu.getSlot(i).mayPlace(new ItemStack(Items.COAL)),"All 27 fuel cells accept fuel");check(!p.containerMenu.getSlot(i).mayPlace(new ItemStack(Items.DIAMOND)),"All 27 fuel cells refuse other items");}});c.takeScreenshot("furnace115-fuel");
c.runOnClient(m->{var screen=(MultiblockScreen)m.gui.screen();screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(Multiblocks.text("how_to_cook").getString())).findFirst().orElseThrow().onPress(enter());check(screen.helpVisible(),"Help button opens its explanation");});c.waitTicks(5);c.takeScreenshot("furnace115-help");
var reload=c.computeOnClient(m->{m.getLanguageManager().setSelected("fr_fr");return m.reloadResourcePacks();});c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(3);c.takeScreenshot("furnace115-help-fr");
System.out.println("FURNACE115_PASS native Shift-drag, consecutive input/fuel mapping, immediate prediction, arbitrary placement, stationary named fuel, heat/output/pause/recipe feedback, fuel budget, refuelling, buckets, save and result extraction");
}finally{intro.set(false);}
}
private static void cookingRegressions(ServerPlayer p){
var stock=new MultiblockContainer(p.level(),oven);reset(stock);stock.setItem(0,new ItemStack(Items.WET_SPONGE));stock.setItem(79,new ItemStack(Items.LAVA_BUCKET));for(int t=0;t<205;t++)MultiblockCooking.tick(p.level(),oven);
check(stock.getItem(2).is(Items.SPONGE)&&stock.getItem(1).is(Items.WATER_BUCKET)&&stock.getItem(79).isEmpty(),"Native lava and wet sponge remainder");
reset(stock);for(int i=0;i<81;i+=3)stock.setItem(i,new ItemStack(Items.RAW_IRON));stock.setItem(79,new ItemStack(Items.COAL));for(int t=0;t<90;t++)MultiblockCooking.tick(p.level(),oven);
int partial=((MultiblockFurnaceAccess)stock.cell(0)).sanctuary$progress();check(partial>0&&partial<200,"One coal cannot cook all 27 items");for(int t=0;t<30;t++)MultiblockCooking.tick(p.level(),oven);check(((MultiblockFurnaceAccess)stock.cell(0)).sanctuary$progress()==partial,"No lost paid progress when out of fuel");
stock.setItem(79,new ItemStack(Items.COAL,4));for(int t=0;t<205;t++)MultiblockCooking.tick(p.level(),oven);int produced=0;for(int i=2;i<81;i+=3)produced+=stock.getItem(i).getCount();check(produced==27,"Refuelling completes all lanes exactly once");
var f=(AbstractFurnaceBlockEntity)stock.cell(0);var restored=(AbstractFurnaceBlockEntity)BlockEntity.loadStatic(f.getBlockPos(),f.getBlockState(),f.saveWithFullMetadata(p.level().registryAccess()),p.level().registryAccess());check(((MultiblockFurnaceAccess)restored).sanctuary$heat()==((MultiblockFurnaceAccess)f).sanctuary$heat(),"Native heat survives save");
Multiblocks.open(p,oven,ORIGIN,0,"",2);var menu=(MultiblockMenu)p.containerMenu;check(!menu.getSlot(0).mayPlace(new ItemStack(Items.STONE)),"Results refuse input");check(!menu.quickMoveStack(p,0).isEmpty()&&stock.getItem(2).isEmpty(),"Native result slot can extract");
}
private static void reset(MultiblockContainer stock){stock.clearContent();for(int i=0;i<81;i+=3){var a=(MultiblockFurnaceAccess)stock.cell(i);a.sanctuary$heat(0);a.sanctuary$progress(0);}}
private static void click(ClientGameTestContext c,int slot,int button,ContainerInput type){c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,slot,button,type,m.player));}
private static void move(ClientGameTestContext c,int index){c.runOnClient(m->{var a=(AbstractContainerScreenAccessor)m.gui.screen();var slot=m.player.containerMenu.getSlot(index);var w=m.getWindow();double x=a.sanctuary$getLeftPos()+slot.x+8,y=a.sanctuary$getTopPos()+slot.y+8;double sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();m.mouseHandler.onMove(w.handle(),x*sx,y*sy,(x-m.mouseHandler.getScaledXPos(w))*sx,(y-m.mouseHandler.getScaledYPos(w))*sy);m.mouseHandler.handleAccumulatedMovement();});c.waitTick();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,88 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.gems.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.registries.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.resources.ResourceKey;
import net.minecraft.tags.*;
import net.minecraft.util.RandomSource;
import net.minecraft.world.item.*;
import net.minecraft.world.item.crafting.*;
import net.minecraft.world.item.enchantment.Enchantments;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.levelgen.GenerationStep;
/** Actual native registration, recipes, drops, tab order and naturally decorated Sanctuary chunks. */
public final class Gems126ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);Starter039TestSupport.selectFirst(screen);var enter=new KeyEvent(InputConstants.KEY_RETURN,0,0);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter);intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Gems126 registry "+UUID.randomUUID());s.setSeed("126");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
world.getServer().runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var l=p.level();var pos=new BlockPos(0,4,0);var iron=new ItemStack(Items.IRON_PICKAXE);var silk=iron.copy();silk.enchant(l.registryAccess().lookupOrThrow(Registries.ENCHANTMENT).getOrThrow(Enchantments.SILK_TOUCH),1);var fortune=iron.copy();fortune.enchant(l.registryAccess().lookupOrThrow(Registries.ENCHANTMENT).getOrThrow(Enchantments.FORTUNE),3);
for(var f:Gems.FAMILIES){
for(var b:List.of(f.block(),f.ore(),f.deepslateOre())){var state=b.defaultBlockState();check(state.is(BlockTags.MINEABLE_WITH_PICKAXE)&&state.is(BlockTags.NEEDS_IRON_TOOL),"Native mining tags "+b);check(iron.isCorrectToolForDrops(state)&&!new ItemStack(Items.STONE_PICKAXE).isCorrectToolForDrops(state),"Iron tier enforced "+b);}
drop(Block.getDrops(f.block().defaultBlockState(),l,pos,null,p,iron),f.block().asItem(),1);
for(var ore:List.of(f.ore(),f.deepslateOre())){
drop(Block.getDrops(ore.defaultBlockState(),l,pos,null,p,iron),f.gem(),1);drop(Block.getDrops(ore.defaultBlockState(),l,pos,null,p,silk),ore.asItem(),1);
int max=0;for(int i=0;i<48;i++){var drops=Block.getDrops(ore.defaultBlockState(),l,pos,null,p,fortune);check(drops.size()==1&&drops.getFirst().is(f.gem()),"Fortune gem type");int n=drops.getFirst().getCount();check(n>=1&&n<=4,"Native Fortune III range");max=Math.max(max,n);}check(max>1,"Fortune increases gem yield");
var input=new SingleRecipeInput(new ItemStack(ore));for(var type:List.of(RecipeType.SMELTING,RecipeType.BLASTING)){var recipe=s.getRecipeManager().getRecipeFor(type,input,l).orElseThrow().value();check(recipe.assemble(input).is(f.gem()),"Native furnace recipe");}
}
var input=CraftingInput.of(3,3,Collections.nCopies(9,new ItemStack(f.gem())));drop(List.of(s.getRecipeManager().getRecipeFor(RecipeType.CRAFTING,input,l).orElseThrow().value().assemble(input)),f.block().asItem(),1);
input=CraftingInput.of(1,1,List.of(new ItemStack(f.block())));drop(List.of(s.getRecipeManager().getRecipeFor(RecipeType.CRAFTING,input,l).orElseThrow().value().assemble(input)),f.gem(),9);
check(f.block().defaultBlockState().is(BlockTags.BEACON_BASE_BLOCKS)&&new ItemStack(f.gem()).is(ItemTags.BEACON_PAYMENT_ITEMS),"Native beacon compatibility");
var feature=l.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(ResourceKey.create(Registries.FEATURE,SanctuaryMod.id("ore_"+f.id()))).value();check(feature instanceof GemOreFeature,"Data feature decoded");
check(!feature.place(l,l.getChunkSource().getGenerator(),RandomSource.create(126),pos),"Gem veins do not run in ordinary flat/vanilla worlds");
fr.koka.sanctuary.recipes.RecipeService.discover(p,f.gem());fr.koka.sanctuary.recipes.RecipeService.refresh(p);check(fr.koka.sanctuary.recipes.RecipeService.knowledge(p).recipes.contains("sanctuary:"+f.id()+"_block"),"Native discovery unlocks storage recipe");
}
int x=0;for(var block:List.of(Blocks.EMERALD_BLOCK,Gems.RUBY.block(),Gems.SAPPHIRE.block(),Blocks.EMERALD_ORE,Gems.RUBY.ore(),Gems.SAPPHIRE.ore(),Blocks.DEEPSLATE_EMERALD_ORE,Gems.RUBY.deepslateOre(),Gems.SAPPHIRE.deepslateOre())){l.setBlockAndUpdate(new BlockPos(x%3,1,x/3),block.defaultBlockState());x++;}
p.teleportTo(1.5,3,7);p.getAbilities().flying=true;p.onUpdateAbilities();p.getInventory().setItem(0,new ItemStack(Items.EMERALD));p.getInventory().setItem(1,new ItemStack(Gems.RUBY.gem()));p.getInventory().setItem(2,new ItemStack(Gems.SAPPHIRE.gem()));p.inventoryMenu.broadcastFullState();
System.out.println("GEMS126_SERVER_PASS eight registered items, six blocks, native loot/Fortune/Silk Touch, twelve recipes, mining tiers, beacons, discoveries and vanilla generation exclusion");
});
c.runOnClient(m->{CreativeModeTabs.tryRebuildTabContents(m.level.enabledFeatures(),true,m.level.registryAccess());order(CreativeModeTabs.INGREDIENTS,Items.EMERALD,Gems.RUBY.gem(),Gems.SAPPHIRE.gem());order(CreativeModeTabs.BUILDING_BLOCKS,Items.EMERALD_BLOCK,Gems.RUBY.block().asItem(),Gems.SAPPHIRE.block().asItem());order(CreativeModeTabs.NATURAL_BLOCKS,Items.EMERALD_ORE,Gems.RUBY.ore().asItem(),Gems.SAPPHIRE.ore().asItem());order(CreativeModeTabs.NATURAL_BLOCKS,Items.DEEPSLATE_EMERALD_ORE,Gems.RUBY.deepslateOre().asItem(),Gems.SAPPHIRE.deepslateOre().asItem());
for(var f:Gems.FAMILIES)for(var block:List.of(f.block(),f.ore(),f.deepslateOre())){var parts=new ArrayList<net.minecraft.client.renderer.block.dispatch.BlockStateModelPart>();m.getModelManager().getBlockStateModelSet().get(block.defaultBlockState()).collectParts(RandomSource.create(0),parts);check(!parts.isEmpty(),"Baked gem block model");for(var part:parts)for(var face:Direction.values())for(var quad:part.getQuads(face))check(!quad.materialInfo().sprite().contents().name().toString().contains("missingno"),"Native texture atlas resolves supplied gem PNG");}
System.out.println("GEMS126_CREATIVE_PASS emerald, ruby, sapphire adjacency in four groups; all six baked block models");
});
c.waitTicks(10);c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new net.minecraft.world.phys.Vec3(1.5,1.5,1.5)));c.waitTicks(4);c.takeScreenshot("gems126-gallery");
Clay126Checks.exercise(c,world.getServer());
Discovery126Checks.exercise(c,world.getServer());
}
intro.set(true);net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;var found=new HashMap<BlockPos,BlockState>();
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Gems126 natural "+UUID.randomUUID());s.setSeed("126");s.setAllowCommands(true);s.setGenerateStructures(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());}).create()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&ProgressionClient.active()&&!IntroSpawnFlash.active(),2400);
world.getServer().runOnServer(s->{var l=s.overworld();check(l.getChunkSource().getGenerator() instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator,"Actual Sanctuary generator");
for(int cx=-4;cx<=4;cx++)for(int cz=-4;cz<=4;cz++){var chunk=l.getChunk(cx,cz);var cursor=new BlockPos.MutableBlockPos();for(int x=0;x<16;x++)for(int z=0;z<16;z++)for(int y=0;y<=256;y++){cursor.set(cx*16+x,y,cz*16+z);var state=chunk.getBlockState(cursor);if(state.is(Gems.RUBY.ore())||state.is(Gems.RUBY.deepslateOre())||state.is(Gems.SAPPHIRE.ore())||state.is(Gems.SAPPHIRE.deepslateOre()))found.put(cursor.immutable(),state);}}
for(var f:Gems.FAMILIES){long ordinary=found.values().stream().filter(b->b.is(f.ore())).count(),deep=found.values().stream().filter(b->b.is(f.deepslateOre())).count();System.out.println("GEMS126_NATURAL "+f.id()+" stone="+ordinary+" deepslate="+deep+" seed=126 chunks=81");check(ordinary+deep>0,"Both gems generate naturally without structure placement: "+f.id());}
var biome=l.getBiome(found.keySet().iterator().next()).value();var features=biome.getGenerationSettings().features().get(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal());for(var f:Gems.FAMILIES)check(features.stream().anyMatch(h->h.is(ResourceKey.create(Registries.PLACED_FEATURE,SanctuaryMod.id("ore_"+f.id())))),"Native biome includes configurable gem placement");
Discovery126Checks.command(s.getPlayerList().getPlayers().getFirst(),"all");
var mined=found.keySet().iterator().next();l.setBlockAndUpdate(mined,Blocks.AIR.defaultBlockState());found.put(mined,Blocks.AIR.defaultBlockState());
});saved=world.getWorldSave();
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null,2400);world.getServer().runOnServer(s->{Discovery126Checks.all(s.getPlayerList().getPlayers().getFirst());for(var e:found.entrySet())check(s.overworld().getBlockState(e.getKey())==e.getValue(),"Generated ores persist on reload without repopulation");});
}
System.out.println("GEMS126_PASS registry, recipes, drops, creative order, native models, natural Sanctuary generation seed 126 and save/reconnect");
}
private static void order(ResourceKey<CreativeModeTab> tab,Item anchor,Item ruby,Item sapphire){var items=new ArrayList<>(BuiltInRegistries.CREATIVE_MODE_TAB.getOrThrow(tab).value().getDisplayItems());int i=0;while(i<items.size()&&!items.get(i).is(anchor))i++;check(i+2<items.size()&&items.get(i+1).is(ruby)&&items.get(i+2).is(sapphire),"Emerald/ruby/sapphire order in "+tab);check(items.stream().filter(v->v.is(ruby)||v.is(sapphire)).count()==2,"No duplicate creative entries");}
private static void drop(List<ItemStack> drops,Item item,int n){check(drops.size()==1&&drops.getFirst().is(item)&&drops.getFirst().getCount()==n,"Native loot/craft expected "+n+" "+item+", got "+drops);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,81 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.companion.CompanionService;
import fr.koka.sanctuary.companion.CompanionWorld;
import net.minecraft.client.KeyMapping;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.CustomData;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.Vec3;
/** Native movement contacts, fall callbacks and real attack regression for every flying profile. */
public final class Landing101ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
var descending=new AtomicReference<FamiliarEntity>();
net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents.END_SERVER_TICK.register(s->{var pet=descending.get();if(pet!=null&&pet.isAlive())pet.move(MoverType.SELF,new Vec3(0,-.25,0));});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Landing101 "+UUID.randomUUID());s.setSeed("101");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.teleportTo(.5,1,.5);p.getInventory().clearContent();s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set night");for(int x=-10;x<=10;x++)for(int z=-10;z<=10;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());});
var flyers=new ArrayList<Item>();c.runOnClient(m->{for(var item:net.minecraft.core.registries.BuiltInRegistries.ITEM)if(item instanceof SpawnEggItem&&FamiliarMotion.of(item.getDefaultInstance()).flies())flyers.add(item);});
for(var item:flyers){
var health=new AtomicReference<Float>();var hurt=new AtomicInteger();
server.runOnServer(s->{var p=player(s);equip(p,item,FamiliarPersonality.PACIFIST,"calm",1000);var pet=FamiliarService.active(p);pet.setNoAi(true);pet.setPos(2.5,7,2.5);pet.setDeltaMovement(Vec3.ZERO);pet.resetFallDistance();health.set(pet.getHealth());hurt.set(FamiliarBattle.state(p).lastHurt);
// Native fall callbacks can also arrive with a precomputed fall distance.
check(!pet.causeFallDamage(7,1,pet.damageSources().fall()),"Flying familiar rejects native fall callback: "+item);
check(pet.getHealth()==health.get()&&FamiliarBattle.state(p).lastHurt==hurt.get(),"No false hurt/combat from landing callback: "+item);
descending.set(pet);
});
c.waitTicks(32);
server.runOnServer(s->{var p=player(s);var pet=descending.getAndSet(null);check(pet.onGround(),"Native contact reached ground: "+item);check(pet.getHealth()==health.get()&&pet.fallDistance==0&&FamiliarBattle.state(p).lastHurt==hurt.get(),"Slow descent remains harmless: "+item);
pet.setPos(2.5,7,2.5);pet.setOnGround(false);pet.move(MoverType.SELF,new Vec3(0,-8,0));check(pet.onGround()&&pet.getHealth()==health.get()&&FamiliarBattle.state(p).lastHurt==hurt.get(),"Fast descent also safe: "+item);
var enemy=enemy(p,4,1,2.5);check(pet.hurtServer(p.level(),p.damageSources().mobAttack(enemy),1),"Genuine hit remains accepted: "+item);check(pet.getHealth()<health.get(),"Genuine hit removes health: "+item);enemy.discard();FamiliarBattle.cancel(p);
System.out.println("LANDING101_SPECIES_PASS "+item);
});
}
server.runOnServer(s->{var p=player(s);equip(p,Items.WOLF_SPAWN_EGG,FamiliarPersonality.PACIFIST,"calm",1000);var pet=FamiliarService.active(p);float before=pet.getHealth();check(pet.causeFallDamage(7,1,pet.damageSources().fall())&&pet.getHealth()<before,"Terrestrial fall damage retained");});
System.out.println("LANDING101_PASS profiles="+flyers.size());
}finally{descending.set(null);}
}
private static void equip(ServerPlayer p,Item item,FamiliarPersonality personality,String mood,int size){
p.stopRiding();CarryService.detach(p);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);p.removeAttached(FamiliarBattle.COMBAT_LOCK);p.removeAttached(FamiliarBattle.LAST_FAMILIAR);
var egg=new ItemStack(item);var scale=new CompoundTag();scale.putInt("schema",1);scale.putInt("per_mille",size);CustomData.update(DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,scale));
var profile=FamiliarBattle.world(p.level().getServer()).catalog.forEgg(egg);UUID id;do{id=UUID.randomUUID();}while(FamiliarPersonality.of(id,profile.id())!=personality||!FamiliarBond.create(id,profile,0,"test",p.getUUID()).temperament().equals(mood));
FamiliarBattle.world(p.level().getServer()).store.put(FamiliarBond.create(id,profile,0,"test",p.getUUID()));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,FamiliarIdentity.assign(egg,id));
CompanionService.tick(p);p.tickCount+=10-p.tickCount%10;FamiliarService.tick(p);check(FamiliarService.active(p)!=null,"Familiar spawned: "+item);p.inventoryMenu.broadcastChanges();
}
private static Mob enemy(ServerPlayer p,double x,double y,double z){var e=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);e.setNoAi(true);e.getAttribute(Attributes.MAX_HEALTH).setBaseValue(100);e.setHealth(100);e.setPos(x,y,z);p.level().addFreshEntity(e);return e;}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,86 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.multiblock.*;
import fr.koka.sanctuary.client.panorama.*;
import fr.koka.sanctuary.client.shader.VanillaLight;
import fr.koka.sanctuary.weather.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.nio.file.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.level.*;
import net.minecraft.world.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.animal.Animal;
import net.minecraft.world.entity.projectile.throwableitemprojectile.Snowball;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.phys.EntityHitResult;
public final class Living105ClientChecks implements FabricClientGameTest {
private static int wearer,cow,pig;private static double cowStart,pigStart,flightStart;
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen s){Starter039TestSupport.selectFirst(s);s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Living105 "+UUID.randomUUID());s.setSeed("105");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=player(s);var l=p.level();s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
for(int x=-12;x<=12;x++)for(int z=-12;z<=12;z++)l.setBlockAndUpdate(new BlockPos(x,79,z),Blocks.GRASS_BLOCK.defaultBlockState());p.teleportTo(0,80,-3);p.getInventory().clearContent();
var m=spawn(l,EntityTypes.COW,0,80,0);m.setNoAi(true);wearer=m.getId();
HeadEquipment.set(m,new ItemStack(Items.WRITABLE_BOOK));var original=new ArrayList<net.minecraft.server.network.Filterable<String>>();original.add(net.minecraft.server.network.Filterable.passThrough("My original page"));HeadEquipment.get(m).set(DataComponents.WRITABLE_BOOK_CONTENT,new WritableBookContent(original));
check(HeadCosmeticService.interact(p,m),"Book responds to native head interaction");check(pages(m)==2,"Pet adds diary entry");check(m.hurtServer(l,p.damageSources().playerAttack(p),1),"Actual hurt event");check(pages(m)==3,"Hurt adds distinct diary entry");check(HeadEquipment.get(m).get(DataComponents.WRITABLE_BOOK_CONTENT).pages().getFirst().raw().equals("My original page"),"Original text preserved");
var shown=HeadCosmeticService.appearance(m);check(shown.get(DataComponents.WRITABLE_BOOK_CONTENT).pages().isEmpty(),"Diary not broadcast in appearance");
p.setShiftKeyDown(true);check(MobHeadService.equip(p,m),"Retrieve written diary");check(p.getInventory().countItem(Items.WRITABLE_BOOK)==1,"Book returned once");p.setShiftKeyDown(false);
p.setItemInHand(InteractionHand.MAIN_HAND,ItemStack.EMPTY);HeadEquipment.set(m,new ItemStack(Items.WHEAT_SEEDS));var head=HeadBlockService.session(m);check(head!=null,"Crop session");head.change(((CropBlock)Blocks.WHEAT).getStateForAge(7));check(HeadBlockService.use(p,m,false),"Harvest mature head crop");check(p.getInventory().countItem(Items.WHEAT)==1,"Actual native wheat loot");check(((CropBlock)Blocks.WHEAT).getAge(head.state)==0,"Replanted after harvest");
head.change(((CropBlock)Blocks.WHEAT).getStateForAge(7));p.setItemInHand(InteractionHand.MAIN_HAND,ItemStack.EMPTY);p.setShiftKeyDown(true);check(MobHeadService.equip(p,m),"Mature crop retrieval");p.setShiftKeyDown(false);
p.getInventory().clearContent();HeadEquipment.set(m,new ItemStack(Items.WHEAT));var a=spawn(l,EntityTypes.COW,7,80,0);cow=a.getId();cowStart=a.distanceToSqr(m);
});
c.waitTicks(100);
server.runOnServer(s->{var m=mob(s,wearer);check(mob(s,cow).distanceToSqr(m)<cowStart/2,"Wheat hat attracts native cow");mob(s,cow).discard();HeadEquipment.set(m,new ItemStack(Items.CARROT_ON_A_STICK));var a=spawn(player(s).level(),EntityTypes.PIG,7,80,0);pig=a.getId();pigStart=a.distanceToSqr(m);});
c.waitTicks(100);
server.runOnServer(s->{var p=player(s);var m=mob(s,wearer);check(mob(s,pig).distanceToSqr(m)<pigStart/2,"Carrot stick guides native pig");mob(s,pig).discard();
HeadEquipment.set(m,new ItemStack(Items.FLINT_AND_STEEL));m.setYHeadRot(0);snowball(p,m);check(p.level().getBlockState(m.blockPosition().south()).is(Blocks.FIRE),"Snowball ignites flint hat without native damage");check(HeadEquipment.get(m).getDamageValue()==1,"Flint durability spent once");p.level().removeBlock(m.blockPosition().south(),false);
});c.waitTicks(12);
server.runOnServer(s->{var p=player(s);var m=mob(s,wearer);m.setNoAi(false);HeadEquipment.set(m,new ItemStack(Items.FIREWORK_ROCKET));flightStart=m.getY();snowball(p,m);check(HeadEquipment.get(m).isEmpty(),"Rocket consumed once");});c.waitTicks(18);
server.runOnServer(s->{var p=player(s);var m=mob(s,wearer);check(m.getY()>flightStart+2,"Firework lifts mob");MobHeadActions.clear(s);m.setNoAi(true);m.setPos(0,80,0);m.setDeltaMovement(0,0,0);HeadEquipment.set(m,new ItemStack(Items.SPAWNER));var head=HeadBlockService.session(m);var be=(SpawnerBlockEntity)head.entity;be.getSpawner().setEntityId(EntityTypes.PIG,p.level(),p.level().getRandom(),head.pos());var tag=be.saveWithoutMetadata(p.level().registryAccess());tag.putShort("Delay",(short)500);tag.putShort("MinSpawnDelay",(short)200);tag.putShort("MaxSpawnDelay",(short)400);be.loadWithComponents(net.minecraft.world.level.storage.TagValueInput.create(net.minecraft.util.ProblemReporter.DISCARDING,p.level().registryAccess(),tag));
int before=p.level().getEntitiesOfClass(net.minecraft.world.entity.animal.pig.Pig.class,m.getBoundingBox().inflate(10)).size();MobHeadActions.activate(m,p.damageSources().playerAttack(p),true);int after=p.level().getEntitiesOfClass(net.minecraft.world.entity.animal.pig.Pig.class,m.getBoundingBox().inflate(10)).size();check(after>before,"Hit runs configured native spawner immediately");HeadEquipment.set(m,new ItemStack(net.minecraft.core.registries.BuiltInRegistries.ITEM.getValue(net.minecraft.resources.Identifier.withDefaultNamespace("lightning_rod"))));
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather thunder");p.level().setThunderLevel(1);p.level().setRainLevel(1);
var at=HeadBlockService.session(m).pos();check(MobHeadActions.lightningTarget(p.level(),at.offset(5,0,0),at.offset(5,0,0)).equals(at.above()),"Mobile rod attracts lightning");var bolt=EntityTypes.LIGHTNING_BOLT.create(p.level(),EntitySpawnReason.COMMAND);bolt.setVisualOnly(true);bolt.setPos(at.getX()+.5,at.getY()+1,at.getZ()+.5);p.level().addFreshEntity(bolt);
});c.waitTicks(2);
server.runOnServer(s->{check(HeadBlockService.session(mob(s,wearer)).emitsPower(),"Native lightning powers head rod");});c.waitTicks(60);
server.runOnServer(s->{var p=player(s);var m=mob(s,wearer);check(!HeadBlockService.session(m).emitsPower(),"Lightning pulse resets");HeadEquipment.set(m,ItemStack.EMPTY);m.discard();s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather snow");p.level().setRainLevel(1);var at=new BlockPos(9,80,9);check(p.level().getBiome(at).value().shouldSnow(p.level(),at),"Temperate biome permits seasonal accumulation");
try{var tick=ServerLevel.class.getDeclaredMethod("tickPrecipitation",BlockPos.class);tick.setAccessible(true);for(int i=0;i<200;i++)tick.invoke(p.level(),at);}catch(Exception e){throw new RuntimeException(e);}check(p.level().getBlockState(at).is(Blocks.SNOW),"Native precipitation accumulates snow");
});c.waitFor(m->WeatherClient.state().kind()==DailyWeather.Kind.SNOW,200);
c.runOnClient(m->check(m.level.getPrecipitationAt(new BlockPos(9,81,9))==net.minecraft.world.level.biome.Biome.Precipitation.SNOW,"Client snow render and audio query"));
server.runOnServer(s->{var p=player(s);s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather clear");var origin=new BlockPos(3,80,1);for(int x=0;x<3;x++)for(int y=0;y<3;y++)for(int z=0;z<3;z++)p.level().setBlockAndUpdate(origin.offset(x,y,z),Blocks.BARREL.defaultBlockState());var f=new MultiblockData.Machine(UUID.randomUUID(),origin,false,Direction.NORTH);check(Multiblocks.assemble(p,f),"Assemble fût");Multiblocks.open(p,f,origin,0,"",0);});
c.waitFor(m->m.gui.screen() instanceof MultiblockScreen,200);c.runOnClient(m->{var screen=(MultiblockScreen)m.gui.screen();var a=screen.extraAreas().getFirst();check(screen.mouseScrolled(a.getX()+5,a.getY()+5,0,-1),"Wheel handled");});c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.page()==1,200);
c.runOnClient(m->{var screen=(MultiblockScreen)m.gui.screen();var a=screen.extraAreas().getFirst();screen.mouseScrolled(a.getX()+5,a.getY()+5,0,100);});c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.page()==0,200);
server.runOnServer(s->player(s).closeContainer());c.waitFor(m->m.gui.screen()==null,200);
c.waitTicks(30);var captures=new ArrayList<Path>();
for(int saturation:new int[]{0,200}){c.runOnClient(m->{VanillaLight.setEnabled(true);VanillaLight.setSaturation(saturation);PanoramaOptions.get().resolution=256;});c.waitTicks(4);c.runOnClient(PanoramaCapture::request);c.waitFor(m->PanoramaCapture.lastCapture().isDone(),400);captures.add(c.computeOnClient(m->PanoramaCapture.lastCapture().join()));}
verifyPanorama(captures);c.runOnClient(m->{VanillaLight.setSaturation(142);PanoramaOptions.get().resolution=1024;});
System.out.println("LIVING105_NATIVE_PASS: diary, crops, lures, snowball, flint, firework, spawner, lightning, snow, barrel scroll, shader panoramas "+captures);
}finally{intro.set(false);}
}
private static void verifyPanorama(List<Path> captures){try{double[] chroma=new double[2];for(int n=0;n<2;n++){var meta=com.google.gson.JsonParser.parseString(Files.readString(captures.get(n).resolve("capture.json"))).getAsJsonObject();check(meta.getAsJsonArray("post_effects").toString().contains("vanilla_light"),"Active effect captured");for(int f=0;f<6;f++){double before=chroma[n];var image=javax.imageio.ImageIO.read(captures.get(n).resolve("panorama_"+f+".png").toFile());for(int y=0;y<256;y++)for(int x=0;x<256;x++){int rgb=image.getRGB(x,y),r=(rgb>>16)&255,g=(rgb>>8)&255,b=rgb&255;chroma[n]+=Math.max(r,Math.max(g,b))-Math.min(r,Math.min(g,b));}if(n==0)check(chroma[n]-before<100000,"Every face is desaturated: "+f+" "+(chroma[n]-before));}var raw=java.nio.ByteBuffer.wrap(Files.readAllBytes(captures.get(n).resolve("depth_5.f32"))).order(java.nio.ByteOrder.LITTLE_ENDIAN);int surfaces=0;while(raw.hasRemaining())if(raw.getFloat()>0)surfaces++;check(surfaces>30000,"Postprocessing preserves terrain depth");}check(chroma[1]>chroma[0]+100000,"Captured saturation changes actual pixels "+Arrays.toString(chroma));}catch(Exception e){throw new RuntimeException(e);}}
private static void snowball(ServerPlayer p,Mob m){var ball=new Snowball(p.level(),p,new ItemStack(Items.SNOWBALL));try{var hit=Snowball.class.getDeclaredMethod("onHitEntity",EntityHitResult.class);hit.setAccessible(true);hit.invoke(ball,new EntityHitResult(m));}catch(Exception e){throw new RuntimeException(e);}}
private static int pages(Mob m){return HeadEquipment.get(m).get(DataComponents.WRITABLE_BOOK_CONTENT).pages().size();}
private static <T extends Mob>T spawn(ServerLevel l,EntityType<T> type,double x,double y,double z){var m=type.create(l,EntitySpawnReason.COMMAND);m.setPos(x,y,z);l.addFreshEntity(m);return m;}
private static ServerPlayer player(net.minecraft.server.MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static Mob mob(net.minecraft.server.MinecraftServer s,int id){return (Mob)s.overworld().getEntity(id);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,105 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.progression.SanctuaryGameplay;
import fr.koka.sanctuary.realtime.RealtimeService;
import fr.koka.sanctuary.weather.SeasonalSnowMelt;
import fr.koka.sanctuary.weather.SeasonalWeather;
import fr.koka.sanctuary.weather.WeatherService;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.SnowLayerBlock;
import net.minecraft.world.level.gamerules.GameRules;
public final class Melt109ClientChecks implements FabricClientGameTest {
private static final BlockPos AT=new BlockPos(4,80,4), PERSIST=new BlockPos(5,80,12);
public void runTest(ClientGameTestContext c) {
new Snow108ClientChecks().runTest(c);
var intro=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());
}});
TestWorldSave saved;
try {
try(var world=c.worldBuilder().adjustSettings(s->{
s.setName("Melt109 "+UUID.randomUUID());s.setSeed("109");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());
s.getGameRules().set(SanctuaryGameplay.ENABLED,true,null);
}).create()) {
saved=world.getWorldSave();
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&ProgressionClient.active()&&!IntroSpawnFlash.active(),1200);
world.getServer().runOnServer(server->{
var p=server.getPlayerList().getPlayers().getFirst();var l=p.level();p.teleportTo(0,80,-5);
for(int x=-15;x<=15;x++)for(int z=-15;z<=15;z++)l.setBlockAndUpdate(new BlockPos(x,79,z),Blocks.STONE.defaultBlockState());
clock(l,"2026-07-10T12:00:00Z",SeasonalWeather.Season.SUMMER);
check(l.getGameRules().get(SeasonalSnowMelt.ENABLED),"Melt enabled by default");
deposit(l,AT,16);check(l.getBlockState(AT.above()).is(Blocks.SNOW_BLOCK),"Default two-block accumulation retained");
clear(l);step(l,AT,1);
check(layers(l,AT.above())==7&&layers(l,AT)==8,"Summer melts only one top layer");
step(l,AT,7);check(l.getBlockState(AT.above()).isAir()&&layers(l,AT)==8,"Top block melts completely before the lower block");
step(l,AT,8);check(l.getBlockState(AT).isAir()&&!tracked(l,AT),"Natural column vanishes without water; receipt removed");
var built=new BlockPos(7,80,4);l.setBlockAndUpdate(built,Blocks.SNOW_BLOCK.defaultBlockState());step(l,built,64);check(layers(l,built)==8,"Untracked snow block preserved");
var old=new BlockPos(8,80,4);snow(l,old,5);step(l,old,64);check(layers(l,old)==5,"Untracked old layers preserved");
var base=new BlockPos(9,80,4);snow(l,base,3);deposit(l,base,5);clear(l);step(l,base,16);check(layers(l,base)==3&&!tracked(l,base),"Weather above manual layers melts only back to its base");
var atop=new BlockPos(9,80,6);l.setBlockAndUpdate(atop,Blocks.SNOW_BLOCK.defaultBlockState());deposit(l,atop,8);clear(l);step(l,atop,16);check(layers(l,atop)==8&&l.getBlockState(atop.above()).isAir(),"Weather on a snow construction melts; construction stays");
var replaced=new BlockPos(10,80,4);deposit(l,replaced,8);l.setBlockAndUpdate(replaced,Blocks.AIR.defaultBlockState());l.setBlockAndUpdate(replaced,Blocks.SNOW_BLOCK.defaultBlockState());clear(l);step(l,replaced,32);check(layers(l,replaced)==8&&!tracked(l,replaced),"Break and replacement cannot inherit melt permission");
var thickened=new BlockPos(10,80,6);deposit(l,thickened,2);snow(l,thickened,6);clear(l);step(l,thickened,32);check(layers(l,thickened)==6&&!tracked(l,thickened),"Manual layer edit relinquishes natural provenance");
var capped=new BlockPos(12,80,6);deposit(l,capped,8);snow(l,capped.above(),1);clear(l);step(l,capped,32);check(layers(l,capped)==8&&layers(l,capped.above())==1,"Manual thin snow cap protects its natural support");
var roof=new BlockPos(11,80,4);deposit(l,roof,8);l.setBlockAndUpdate(roof.above(3),Blocks.STONE.defaultBlockState());clear(l);step(l,roof,32);check(layers(l,roof)==8,"Covered snow remains untouched");l.setBlockAndUpdate(roof.above(3),Blocks.AIR.defaultBlockState());step(l,roof,1);check(layers(l,roof)==7,"Uncovered natural snow resumes melt");
command(l,"gamerule sanctuary:seasonal_snow_melt false");step(l,roof,32);check(layers(l,roof)==7,"Operator disables melting");command(l,"gamerule sanctuary:seasonal_snow_melt true");
clock(l,"2026-01-10T12:00:00Z",SeasonalWeather.Season.WINTER);clear(l);step(l,roof,64);check(layers(l,roof)==7,"Winter never melts even with clear weather");
clock(l,"2026-04-10T12:00:00Z",SeasonalWeather.Season.SPRING);clear(l);step(l,roof,64);check(l.getBlockState(roof).isAir(),"Spring progressively melts");
deposit(l,roof,8);clock(l,"2026-10-10T12:00:00Z",SeasonalWeather.Season.AUTUMN);clear(l);step(l,roof,128);check(l.getBlockState(roof).isAir(),"Autumn progressively melts");
deposit(l,roof,8);clock(l,"2026-07-10T12:00:00Z",SeasonalWeather.Season.SUMMER);
command(l,"sanctuary weather snow");l.getWeatherData().setRaining(false);l.setRainLevel(0);step(l,roof,64);check(layers(l,roof)==8,"Forced summer snowfall blocks melting");
command(l,"sanctuary weather snow_showers");l.getWeatherData().setRaining(false);l.setRainLevel(0);step(l,roof,64);check(layers(l,roof)==8,"Pauses in snow showers also block melting");
clear(l);command(l,"sanctuary weather vanilla");step(l,roof,64);check(layers(l,roof)==8,"Vanilla mode does not apply seasonal melt");
var cold=new BlockPos(-4,80,-4);deposit(l,cold,8);command(l,"fillbiome -8 76 -8 -1 88 -1 minecraft:snowy_plains");clear(l);step(l,cold,64);check(layers(l,cold)==8,"Permanently cold biome retains natural snow");
deposit(l,PERSIST,16);clear(l);command(l,"gamerule sanctuary:seasonal_snow_melt false");
check(tracked(l,PERSIST)&&tracked(l,PERSIST.above()),"Both persisted blocks have weather provenance");
});
}
try(var world=saved.open()) {
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&ProgressionClient.active(),1200);
world.getConnection().waitForChunksDownload();c.waitTicks(20);
world.getServer().runOnServer(server->{
var l=server.overworld();clock(l,"2026-07-10T12:00:00Z",SeasonalWeather.Season.SUMMER);clear(l);
check(!l.getGameRules().get(SeasonalSnowMelt.ENABLED),"Melt rule persists across restart");
check(layers(l,PERSIST)==8&&layers(l,PERSIST.above())==8&&tracked(l,PERSIST)&&tracked(l,PERSIST.above()),"Chunk attachment survives native save/reload");
command(l,"gamerule sanctuary:seasonal_snow_melt true");step(l,PERSIST,16);
check(l.getBlockState(PERSIST).isAir()&&l.getBlockState(PERSIST.above()).isAir()&&!tracked(l,PERSIST),"Loaded natural snow melts and clears provenance: base="+l.getBlockState(PERSIST)+", top="+l.getBlockState(PERSIST.above())+", sky="+l.canSeeSky(PERSIST.above(2))+", active="+WeatherService.active(l)+", biome="+l.getBiome(PERSIST)+", season="+WeatherService.season(server));
check(layers(l,new BlockPos(7,80,4))==8&&layers(l,new BlockPos(9,80,4))==3,"Built and preexisting snow preserved across restart");
});
}
System.out.println("MELT109_NATIVE_PASS: accumulation regression, top-down melt, seasons, profiles, cold biomes, constructions, edit invalidation, gamerule, native chunk save/reload");
}finally{intro.set(false);}
}
private static void deposit(ServerLevel l,BlockPos pos,int count){l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,1,l.getServer());command(l,"sanctuary weather snow");l.getWeatherData().setRaining(true);l.setRainLevel(1);step(l,pos,count);l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,0,l.getServer());}
private static void clear(ServerLevel l){command(l,"sanctuary weather clear");l.getWeatherData().setRaining(false);l.setRainLevel(0);}
private static void clock(ServerLevel l,String time,SeasonalWeather.Season expected){try{var field=RealtimeService.Session.class.getDeclaredField("clock");field.setAccessible(true);field.set(RealtimeService.session(l.getServer()),Clock.fixed(Instant.parse(time),ZoneOffset.UTC));check(WeatherService.season(l.getServer())==expected,"Test calendar: "+expected);}catch(ReflectiveOperationException e){throw new AssertionError(e);}}
private static boolean tracked(ServerLevel l,BlockPos p){var m=l.getChunkAt(p).getAttached(SeasonalSnowMelt.NATURAL);return m!=null&&m.containsKey(Long.toString(p.asLong()));}
private static void snow(ServerLevel l,BlockPos p,int layers){l.setBlockAndUpdate(p,Blocks.SNOW.defaultBlockState().setValue(SnowLayerBlock.LAYERS,layers));}
private static int layers(ServerLevel l,BlockPos p){return SeasonalSnowMelt.layers(l.getBlockState(p));}
private static void step(ServerLevel l,BlockPos p,int n){for(int i=0;i<n;i++)l.tickPrecipitation(p);}
private static void command(ServerLevel l,String s){l.getServer().getCommands().performPrefixedCommand(l.getServer().createCommandSourceStack(),s);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -59,10 +59,10 @@ public final class Menus083ClientChecks implements FabricClientGameTest {
click(c,"sanctuary.arena.title");c.waitFor(m->m.gui.screen() instanceof ArenaScreen&&ArenaClient.view!=null,100);
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen() instanceof PauseScreen,"Arena returns to pause"));
c.runOnClient(m->{ArenaClient.open(m.gui.screen());m.gui.screen().onClose();});c.waitTicks(8);c.runOnClient(m->check(m.gui.screen() instanceof PauseScreen,"Late arena response does not reopen it"));
click(c,"sanctuary.progression.progress");c.runOnClient(m->{check(find(m.gui.screen(),"sanctuary.arena.title")==null,"No combat in progression");check(find(m.gui.screen(),"sanctuary.cycle.faction")==null,"No faction in progression");for(String tab:List.of("cycle","log"))button(m.gui.screen(),"sanctuary.cycle."+tab);button(m.gui.screen(),"gui.advancements");});
click(c,"sanctuary.progression.progress");c.runOnClient(m->{check(find(m.gui.screen(),"sanctuary.arena.title")==null,"No combat in progression");check(find(m.gui.screen(),"sanctuary.cycle.faction")==null,"No faction in progression");for(String tab:List.of("cycle","log"))button(m.gui.screen(),"sanctuary.cycle."+tab);check(widgets(m.gui.screen()).anyMatch(InlineAdvancements.class::isInstance),"Embedded native tree");});
c.takeScreenshot("menus083-progress-"+locale+"-"+scale);
c.runOnClient(m->{var screen=(ProgressionScreen)m.gui.screen();check(button(screen,"sanctuary.cycle.cycle").getY()>=screen.contentTop+12,"XP has separate line");check(button(screen,"gui.advancements").getX()<screen.width/2,"Advancements above left skills");});
click(c,"gui.advancements");trackDiet(c);c.runOnClient(m->check(m.gui.screen() instanceof net.minecraft.client.gui.screens.advancements.AdvancementsScreen,"Full native advancements"));c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen() instanceof ProgressionScreen,"Advancements returns to progression"));c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
c.runOnClient(m->{var screen=(ProgressionScreen)m.gui.screen();check(button(screen,"sanctuary.cycle.cycle").getY()>=screen.contentTop+12,"XP has separate line");check(widgets(screen).filter(InlineAdvancements.class::isInstance).map(InlineAdvancements.class::cast).findFirst().orElseThrow().getX()<screen.width/2,"Advancements below left skills");});
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
}
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());});
click(c,"sanctuary.progression.blocks");click(c,"sanctuary.discovery.recipes");click(c,"sanctuary.recipes.buy");c.waitFor(m->RecipeClient.knowledge!=null&&RecipeClient.knowledge.learned(),100);
@@ -0,0 +1,190 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import java.util.stream.Stream;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.*;
import net.minecraft.world.level.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.phys.*;
/** Physical workshop, selection boundary, native paid construction and reversible nine-cell assembly. */
public final class Metatable096ClientChecks implements FabricClientGameTest {
private static final BlockPos O=new BlockPos(15,80,15);
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
button(screen,"sanctuary.progression.enter").onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Metatable096 "+UUID.randomUUID());s.setSeed("96");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=player(s);fr.koka.sanctuary.companion.CompanionService.save(p,fr.koka.sanctuary.companion.CompanionService.record(p).note("seen_mobs","creeper"));var l=p.level();p.setGameMode(GameType.CREATIVE);p.setNoGravity(true);p.getAbilities().flying=true;p.onUpdateAbilities();
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
for(int x=9;x<27;x++)for(int z=9;z<27;z++)l.setBlockAndUpdate(new BlockPos(x,80,z),Blocks.STONE.defaultBlockState());
for(var pos:Metatable.members(O))l.setBlockAndUpdate(pos,Blocks.CRAFTING_TABLE.defaultBlockState());
p.getInventory().setItem(p.getInventory().getSelectedSlot(),ItemStack.EMPTY);home(p);
});
c.waitFor(m->m.level.getBlockState(O).is(Blocks.CRAFTING_TABLE)&&m.player.getY()>80,200);
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.options.bobView().set(false);m.getWindow().setWindowed(1280,800);});
c.getInput().pressKey(InputConstants.KEY_K);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen()==null,"K cannot open catalogue without visiting workshop"));
server.runOnServer(s->{var p=player(s);Metatable.wrench(p,O);check(Metatable.state(p.level(),O,false)==1,"First click only previews");Metatable.wrench(p,O.offset(1,0,1));check(Metatable.state(p.level(),O,true)==1,"Second click assembles nine parts");});
c.waitFor(m->Metatable.state(m.level,O,true)==1,200);
open(c);c.runOnClient(m->{check(((PlansScreen)m.gui.screen()).isWorkshop(),"Physical interaction opens workshop");for(String t:List.of("statue","machines","buildings","library"))button(m.gui.screen(),"sanctuary.plans.tab."+t);});
c.takeScreenshot("metabli096-catalog-en");
c.runOnClient(m->{for(String category:List.of("recent","infrastructure","decoration"))button(m.gui.screen(),"sanctuary.plans.tab."+category);});
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.tab.decoration").onPress(enter()));c.takeScreenshot("catalog099-decoration-en");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.tab.statue").onPress(enter()));c.takeScreenshot("catalog099-statues-en");
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.plans.tab.machines").onPress(enter());});c.takeScreenshot("metabli096-machines-en");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.catalog.piston").onPress(enter()));
c.waitFor(m->m.gui.screen()==null&&PlanClient.plan!=null,100);
c.runOnClient(m->check(PlanClient.plan.cells().size()==9&&!MetatableClient.available(),"Selection closes catalogue and releases selection access"));
cockpit(c);
var selected=new AtomicReference<BuildPlan>();
c.runOnClient(m->{var screen=(PlansScreen)m.gui.screen();check(!screen.isWorkshop(),"K opens only field controls");
check(widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).noneMatch(b->key(b).startsWith("sanctuary.plans.tab.")||Set.of("sanctuary.plans.clear","sanctuary.plans.generate").contains(key(b))),"No remote catalogue or abandon controls");
selected.set(PlanClient.plan);var origin=PlanClient.origin;button(screen,"sanctuary.plans.move.east").onPress(enter());check(PlanClient.origin.equals(origin.east()),"Move by one block");
button(screen,"sanctuary.plans.rotate").onPress(enter());check(PlanClient.plan.width()==1&&PlanClient.plan.depth()==3,"Rotation transforms footprint");
for(int i=0;i<3;i++)PlanClient.rotate();check(PlanClient.plan.equals(selected.get()),"Four rotations restore plan");
PlanClient.catalog(ConstructionCatalog.ENTRIES.getFirst());check(PlanClient.plan.equals(selected.get()),"Remote selection refused");
screen.onFilesDrop(List.of(PlanClient.directory().resolve("missing.schem")));check(!PlanClient.busy,"Remote import refused before reading files");
});
c.takeScreenshot("metabli096-controls-en");close(c);
// Tap K anchors at the actual 96-block ray hit, repeat rotates around that same anchor.
c.getInput().lookAt(new BlockPos(23,80,23));c.waitTicks(3);
var tapped=new AtomicReference<BlockPos>();
c.getInput().pressKey(InputConstants.KEY_K);c.waitTicks(3);
c.runOnClient(m->{check(m.gui.screen()==null,"K tap never opens cockpit");var hit=(BlockHitResult)m.player.pick(96,1,false);var target=hit.getBlockPos().relative(hit.getDirection());check(PlanClient.origin.equals(target.offset(-PlanClient.plan.width()/2,0,-PlanClient.plan.depth()/2)),"K positions at the looked-at face, not near the player");tapped.set(PlanClient.origin);selected.set(PlanClient.plan);});
c.getInput().pressKey(InputConstants.KEY_K);c.waitTicks(3);
c.runOnClient(m->check(PlanClient.plan.equals(selected.get().rotate()),"Second K on same point rotates exactly once"));
c.getInput().lookAt(new BlockPos(16,250,13));c.waitTicks(3);c.runOnClient(m->tapped.set(PlanClient.origin));
c.getInput().pressKey(InputConstants.KEY_K);c.waitTicks(3);c.runOnClient(m->check(PlanClient.origin.equals(tapped.get()),"Looking into the sky keeps the old placement"));
cockpit(c);c.runOnClient(m->check(PlanClient.origin.equals(tapped.get()),"Holding K opens controls without moving or rotating"));close(c);
// Walking away from the workstation never grants catalogue access through K.
server.runOnServer(s->player(s).teleportTo(24,82,24));c.waitFor(m->m.player.getX()>23,100);
cockpit(c);c.runOnClient(m->check(!((PlansScreen)m.gui.screen()).isWorkshop(),"Field options work away from the table"));close(c);
server.runOnServer(s->home(player(s)));c.waitFor(m->m.player.getZ()<15,100);open(c);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.tab.infrastructure").onPress(enter()));c.takeScreenshot("metabli096-buildings-en");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.catalog.footbridge").onPress(enter()));c.waitFor(m->m.gui.screen()==null&&PlanClient.plan.depth()==7,100);
c.runOnClient(m->{PlanClient.origin=new BlockPos(20,81,15);PlanClient.refresh();check(PlanClient.completed==0,"Preview changes no world blocks");});
server.runOnServer(s->{var p=player(s);p.setGameMode(GameType.SURVIVAL);p.setNoGravity(false);p.getInventory().setItem(p.getInventory().getSelectedSlot(),new ItemStack(Items.OAK_PLANKS,2));p.teleportTo(20.5,81,13.5);});
c.waitFor(m->!m.player.isCreative()&&m.player.getX()>20&&m.player.getMainHandItem().is(Items.OAK_PLANKS),150);
c.getInput().lookAt(new BlockPos(20,80,15));c.waitTicks(3);c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);
c.waitFor(m->PlanClient.completed==1,150);
server.runOnServer(s->check(player(s).getMainHandItem().getCount()==1,"Native manual placement consumes one object"));
c.takeScreenshot("metabli096-manual-preview");
// A catalogue visit is required again. Closing while a statue computes cancels that selection.
server.runOnServer(s->{var p=player(s);p.getInventory().setItem(p.getInventory().getSelectedSlot(),ItemStack.EMPTY);home(p);});
c.waitFor(m->m.player.getX()<18&&m.player.getMainHandItem().isEmpty(),100);open(c);
c.runOnClient(m->{selected.set(PlanClient.plan);configure(m.gui.screen());button(m.gui.screen(),"sanctuary.plans.generate").onPress(enter());m.gui.screen().onClose();});
c.waitFor(m->!PlanClient.busy,400);c.runOnClient(m->check(PlanClient.plan==selected.get(),"Closing workshop discards asynchronous new selection"));
open(c);c.runOnClient(m->{configure(m.gui.screen());button(m.gui.screen(),"sanctuary.plans.generate").onPress(enter());});
c.waitFor(m->!PlanClient.busy&&PlanClient.plan!=selected.get()&&m.gui.screen()==null,400);
c.runOnClient(m->check(PlanClient.plan.height()==32&&PlanClient.plan.cells().size()>100,"Statue generation is available at the workshop"));
open(c);language(c,"fr_fr");c.takeScreenshot("metabli096-catalog-fr");
close(c);
server.runOnServer(s->{var p=player(s);var holder=s.getAdvancements().get(net.minecraft.resources.Identifier.withDefaultNamespace("story/mine_stone"));for(String criterion:holder.value().criteria().keySet())p.getAdvancements().award(holder,criterion);});
c.waitTicks(10);c.runOnClient(m->m.gui.setScreen(new ProgressionScreen(null)));c.waitTicks(6);c.takeScreenshot("catalog099-progression-fr");
c.runOnClient(m->{var screen=m.gui.screen();check(widgets(screen).anyMatch(InlineAdvancements.class::isInstance),"Native advancements embedded under skills");check(widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).noneMatch(b->key(b).equals("gui.advancements")),"No separate advancements navigation button");});
c.runOnClient(m->{try{
var embedded=widgets(m.gui.screen()).filter(InlineAdvancements.class::isInstance).map(InlineAdvancements.class::cast).findFirst().orElseThrow();
var method=InlineAdvancements.class.getDeclaredMethod("tab");method.setAccessible(true);var tab=(net.minecraft.client.gui.screens.advancements.AdvancementTab)method.invoke(embedded);
var access=(fr.koka.sanctuary.mixin.client.NotificationAdvancementTabAccess)tab;
var node=access.sanctuary$widgets().values().stream().filter(w->NotificationClient.canTrack(w.getAdvancement())).findFirst().orElseThrow();
tab.scroll(100-node.getX()-access.sanctuary$scrollX(),45-node.getY()-access.sanctuary$scrollY());
float scale=Math.min(1,(embedded.getWidth()-4)/234f);int x=embedded.getX()+(embedded.getWidth()-Math.round(234*scale))/2,y=embedded.getY()+27;
var event=new net.minecraft.client.input.MouseButtonEvent(x+(Math.floor(access.sanctuary$scrollX())+node.getX()+12)*scale,y+(Math.floor(access.sanctuary$scrollY())+node.getY()+12)*scale,new net.minecraft.client.input.MouseButtonInfo(1,0));
boolean before=NotificationClient.isPinned(node.getAdvancement().id());m.gui.screen().mouseClicked(event,false);m.gui.screen().mouseReleased(event);
check(NotificationClient.isPinned(node.getAdvancement().id())!=before,"Embedded tree right-click toggles notification tracking");
}catch(ReflectiveOperationException e){throw new AssertionError(e);}});
c.runOnClient(m->{m.options.guiScale().set(3);m.resizeGui();});c.waitTicks(5);c.takeScreenshot("catalog099-progression-scale3-fr");
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();});close(c);open(c);
// Export/import remains available, but file drops only select at the workshop.
c.runOnClient(m->PlanClient.export());c.waitFor(m->!PlanClient.busy,200);
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.plans.tab.library").onPress(enter());});c.takeScreenshot("metabli096-library-fr");
c.runOnClient(m->{try(var files=java.nio.file.Files.list(PlanClient.directory())){
var path=files.filter(p->p.toString().endsWith(".litematic")).findFirst().orElseThrow();((PlansScreen)m.gui.screen()).onFilesDrop(List.of(path));
}catch(java.io.IOException e){throw new AssertionError(e);}});
c.waitFor(m->!PlanClient.busy&&m.gui.screen()==null,300);
cockpit(c);c.takeScreenshot("metabli096-controls-fr");close(c);
// Breaking the workshop while its menu is open invalidates selection, but retains the player's plan.
open(c);c.runOnClient(m->selected.set(PlanClient.plan));
server.runOnServer(s->{var p=player(s);p.level().destroyBlock(O,true);
check(Metatable.members(O).stream().filter(q->!q.equals(O)).allMatch(q->p.level().getBlockState(q).is(Blocks.CRAFTING_TABLE)),"Eight remaining parts restore without drops");
int drops=p.level().getEntitiesOfClass(ItemEntity.class,new AABB(O).inflate(4)).stream().filter(e->e.getItem().is(Items.CRAFTING_TABLE)).mapToInt(e->e.getItem().getCount()).sum();check(drops==1,"Broken component drops one crafting table");
});
c.waitFor(m->m.gui.screen()==null,100);c.runOnClient(m->check(PlanClient.plan==selected.get(),"Table destruction leaves existing preview intact"));
server.runOnServer(s->{var p=player(s);p.level().setBlockAndUpdate(O,Blocks.CRAFTING_TABLE.defaultBlockState());check(Metatable.assemble(p,O),"Reassemble");});
c.waitFor(m->Metatable.state(m.level,O,true)==1,100);
server.runOnServer(s->{var p=player(s);p.setGameMode(GameType.CREATIVE);p.setNoGravity(true);p.getAbilities().flying=true;p.onUpdateAbilities();p.teleportTo(21.5,84,12.5);});
c.waitTicks(8);c.getInput().lookAt(O.offset(1,0,1));c.waitTicks(10);c.runOnClient(m->{PlanClient.visible=false;if(!m.gui.hud.isHidden())m.gui.hud.toggle();});c.waitTicks(8);c.takeScreenshot("metabli096-textures");
server.runOnServer(s->player(s).teleportTo(400,90,400));c.waitFor(m->m.level.getChunkSource().getChunkNow(O.getX()>>4,O.getZ()>>4)==null,600);
server.runOnServer(s->home(player(s)));c.waitFor(m->Metatable.state(m.level,O,true)==1,500);
server.waitFor(s->Metatable.state(player(s).level(),O,true)==1,200);
server.runOnServer(s->{Metatable.dissolve(player(s).level(),O);check(Metatable.state(player(s).level(),O,false)==1,"Dissociation restores all nine original blocks");});
server.runOnServer(s->{
var l=player(s).level();
for(boolean desert:new boolean[]{true,false}){
var holder=l.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.STRUCTURE).getOrThrow(net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.STRUCTURE,net.minecraft.resources.Identifier.withDefaultNamespace(desert?"desert_pyramid":"jungle_pyramid")));
net.minecraft.world.level.levelgen.structure.StructurePiece piece=desert?new net.minecraft.world.level.levelgen.structure.structures.DesertPyramidPiece(net.minecraft.util.RandomSource.create(97),0,0):new net.minecraft.world.level.levelgen.structure.structures.JungleTemplePiece(net.minecraft.util.RandomSource.create(97),0,0);
var start=new net.minecraft.world.level.levelgen.structure.StructureStart(holder.value(),new ChunkPos(0,0),0,new net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer(List.of(piece)));
((fr.koka.sanctuary.expansion.NativeStructureMetadata23)(Object)start).sanctuary$native23(new fr.koka.sanctuary.expansion.NativeStructureMetadata23.Data(97,-64));
Temple097ClientChecks.foundationChecks(l,start);
}
var p=player(s);p.teleportTo(18.5,84,8);p.setDeltaMovement(Vec3.ZERO);
});
c.waitTicks(10);
c.runOnClient(m->{
var states=List.of(Blocks.COBBLESTONE.defaultBlockState(),Blocks.OAK_PLANKS.defaultBlockState(),Blocks.BRICKS.defaultBlockState(),Blocks.OAK_LOG.defaultBlockState(),Blocks.FURNACE.defaultBlockState(),Blocks.CHEST.defaultBlockState(),Blocks.OAK_STAIRS.defaultBlockState(),Blocks.OAK_LEAVES.defaultBlockState());
var cells=new ArrayList<BuildPlan.Cell>();for(int i=0;i<states.size();i++)cells.add(new BuildPlan.Cell(new BlockPos(i,0,0),states.get(i)));
PlanClient.select(new BuildPlan("Textures",8,1,1,cells),Map.of());PlanClient.origin=new BlockPos(15,82,15);PlanClient.refresh();
if(m.gui.hud.isHidden())m.gui.hud.toggle();check(PlanClient.preview.size()==8,"Every material has a textured preview cell");
});
c.getInput().lookAt(new BlockPos(19,82,15));c.waitTicks(10);c.takeScreenshot("preview097-materials-fr");
c.runOnClient(m->{PlanClient.rotate();check(PlanClient.plan.width()==1&&PlanClient.plan.depth()==8,"Native textured block states rotate with the plan");PlanClient.visible=false;});
c.waitTicks(5);c.takeScreenshot("preview097-hidden");
System.out.println("PREVIEW097_PASS: native textured materials, stairs and container models, rotation, visibility; optional temple foundation clipping and NBT for desert and jungle");
language(c,"en_us");
System.out.println("METATABLE096_PASS: two-click assembly, four-chunk sync, physical catalogue, one selection, field controls, no remote import/switch, native paid placement, asynchronous cancellation, statue + import, FR/EN, destruction/drop/reassembly/unload/dissociation");
}
}
private static void home(ServerPlayer p){p.teleportTo(16.5,81,13.8);p.setDeltaMovement(Vec3.ZERO);}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static void open(ClientGameTestContext c){
c.runOnClient(m->{var at=O.offset(1,0,0);m.gameMode.useItemOn(m.player,net.minecraft.world.InteractionHand.MAIN_HAND,new BlockHitResult(Vec3.atCenterOf(at).add(0,.5,0),Direction.UP,at,false));});
c.waitFor(m->m.gui.screen() instanceof PlansScreen screen&&screen.isWorkshop(),150);
}
private static void close(ClientGameTestContext c){c.runOnClient(m->{if(m.gui.screen()!=null)m.gui.screen().onClose();});}
private static void cockpit(ClientGameTestContext c){c.getInput().holdKey(InputConstants.KEY_K);c.waitTicks(12);c.getInput().releaseKey(InputConstants.KEY_K);c.waitForScreen(PlansScreen.class);}
private static void configure(Screen screen){button(screen,"sanctuary.plans.tab.statue").onPress(enter());button(screen,"sanctuary.plans.configure").onPress(enter());widgets(screen).filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow().setValue("minecraft:creeper");}
private static void language(ClientGameTestContext c,String lang){var reload=c.computeOnClient(m->{m.getLanguageManager().setSelected(lang);return m.reloadResourcePacks();});c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();}
private static Stream<GuiEventListener> widgets(GuiEventListener node){return Stream.concat(Stream.of(node),node instanceof ContainerEventHandler c?c.children().stream().flatMap(Metatable096ClientChecks::widgets):Stream.empty());}
private static String key(Button b){return b.getMessage().getContents() instanceof TranslatableContents t?t.getKey():b.getMessage().getString();}
private static Button button(Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals(key)).findFirst().orElseThrow(()->new AssertionError("Missing button "+key));}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -3,6 +3,7 @@ package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.plans.*;
import fr.koka.sanctuary.multiblock.Metatable;
import java.util.*;
import java.util.concurrent.atomic.*;
import java.util.stream.Stream;
@@ -56,13 +57,16 @@ public final class Plans072ClientChecks implements FabricClientGameTest {
});
require(supported.containsAll(List.of("minecraft:creeper","minecraft:cow","minecraft:pig","minecraft:chicken","minecraft:zombie","minecraft:sheep")),"Common native models are supported");
System.out.println("PLANS072 models="+supported.size()+" refused="+refused+" capture_ms="+(System.nanoTime()-started)/1_000_000);
c.getInput().pressKey(InputConstants.KEY_K);c.waitForScreen(PlansScreen.class);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var o=new BlockPos(0,9,2);for(var q:Metatable.members(o))p.level().setBlockAndUpdate(q,Blocks.CRAFTING_TABLE.defaultBlockState());fr.koka.sanctuary.companion.CompanionService.save(p,fr.koka.sanctuary.companion.CompanionService.record(p).note("seen_mobs","creeper"));require(Metatable.assemble(p,o),"Assemble the physical workshop");});
c.waitFor(m->Metatable.state(m.level,new BlockPos(0,9,2),true)==1,150);
server.runOnServer(s->Metatable.open(s.getPlayerList().getPlayers().getFirst(),new BlockPos(0,9,2)));c.waitForScreen(PlansScreen.class);
c.takeScreenshot("plans072-create-en");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.generate").onPress(enter()));
c.runOnClient(m->{configure(m.gui.screen());button(m.gui.screen(),"sanctuary.plans.generate").onPress(enter());});
c.waitFor(m->PlanClient.plan!=null&&!PlanClient.busy,400);
c.runOnClient(m->{require(PlanClient.plan.height()==32,"Requested model height");require(PlanClient.plan.cells().size()>100,"Detailed geometry becomes real material cells");
PlanClient.origin=new BlockPos(10,2,10);PlanClient.refresh();
});
cockpit(c);
c.takeScreenshot("plans072-plan-en");
var expected=new AtomicReference<BuildPlan>();c.runOnClient(m->expected.set(PlanClient.plan));
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.rotate").onPress(enter()));
@@ -74,19 +78,49 @@ public final class Plans072ClientChecks implements FabricClientGameTest {
c.runOnClient(m->m.gui.setScreen(null));c.waitFor(m->m.player.distanceToSqr(15.5,15,55.5)<2,100);
c.getInput().lookAt(new BlockPos(15,18,17));c.waitTicks(15);
c.getInput().pressKey(InputConstants.KEY_F1);c.takeScreenshot("plans072-ghost-creeper");c.getInput().pressKey(InputConstants.KEY_F1);
c.getInput().pressKey(InputConstants.KEY_K);c.waitForScreen(PlansScreen.class);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.paste").onPress(enter()));c.waitForScreen(ConfirmScreen.class);
c.clickScreenButton("gui.yes");
cockpit(c);
// Creative builds use the same native controls and confirmation as a player.
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.paste").onPress(enter()));
c.waitForScreen(ConfirmScreen.class);c.takeScreenshot("plans098-confirm-en");
c.runOnClient(m->button(m.gui.screen(),"gui.no").onPress(enter()));
c.waitForScreen(PlansScreen.class);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();require(p.level().getBlockState(new BlockPos(10,2,10).offset(expected.get().cells().getFirst().pos())).isAir(),"Cancel leaves plan unbuilt");});
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.paste").onPress(enter()));
c.waitForScreen(ConfirmScreen.class);
c.runOnClient(m->button(m.gui.screen(),"gui.yes").onPress(enter()));
c.waitFor(m->PlanClient.completed==PlanClient.plan.cells().size(),400);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();for(var cell:expected.get().cells())require(p.level().getBlockState(new BlockPos(10,2,10).offset(cell.pos()))==cell.state(),"Server pasted every exact material");});
c.runOnClient(m->m.gui.setScreen(null));c.getInput().pressKey(InputConstants.KEY_F1);c.waitTicks(10);c.takeScreenshot("plans072-built-creeper");c.getInput().pressKey(InputConstants.KEY_F1);
// Select real machine/building catalogue entries, then build through K.
for(String id:List.of("fourneau","shelter")){
c.waitTicks(25); // Respect the server's one-second upload rate limit.
var origin=new BlockPos(id.equals("fourneau")?30:40,4,10);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(.5,10,.5);});
c.waitFor(m->m.player.distanceToSqr(.5,10,.5)<1,100);
server.runOnServer(s->Metatable.open(s.getPlayerList().getPlayers().getFirst(),new BlockPos(0,9,2)));c.waitForScreen(PlansScreen.class);
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.plans.tab."+(id.equals("fourneau")?"machines":"buildings")).onPress(enter());button(m.gui.screen(),"sanctuary.plans.catalog."+id).onPress(enter());});
c.waitFor(m->m.gui.screen()==null,100);
c.runOnClient(m->{PlanClient.origin=origin;PlanClient.rotate();PlanClient.layer=0;PlanClient.refresh();expected.set(PlanClient.plan);});
cockpit(c);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.paste").onPress(enter()));c.waitForScreen(ConfirmScreen.class);
c.runOnClient(m->button(m.gui.screen(),"gui.yes").onPress(enter()));
c.waitFor(m->PlanClient.completed==PlanClient.plan.cells().size(),200);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();for(var cell:expected.get().cells())require(p.level().getBlockState(origin.offset(cell.pos()))==cell.state(),"Full rotated catalogue plan built: "+id);});
}
// Losing creative while the confirmation is open must neither build nor keep its button.
c.runOnClient(m->{PlanClient.select(new BuildPlan("Mode change",1,1,1,List.of(new BuildPlan.Cell(BlockPos.ZERO,Blocks.STONE.defaultBlockState()))),Map.of());PlanClient.origin=new BlockPos(4,2,4);PlanClient.refresh();});
cockpit(c);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.paste").onPress(enter()));c.waitForScreen(ConfirmScreen.class);
// A real survival connection must reject an upload, even when sent without the GUI.
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setGameMode(GameType.SURVIVAL);p.getAbilities().invulnerable=true;});
c.waitFor(m->!m.player.isCreative(),100);
c.runOnClient(m->button(m.gui.screen(),"gui.yes").onPress(enter()));c.waitForScreen(PlansScreen.class);
c.runOnClient(m->{require(widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).noneMatch(b->key(b).equals("sanctuary.plans.paste")),"Creative button disappears after mode change");m.gui.screen().onClose();});
server.runOnServer(s->require(s.getPlayerList().getPlayers().getFirst().level().getBlockState(new BlockPos(4,2,4)).isAir(),"No build after losing creative"));
c.runOnClient(m->{var tiny=new BuildPlan("Survival",1,1,1,List.of(new BuildPlan.Cell(BlockPos.ZERO,Blocks.STONE.defaultBlockState())));PlanClient.select(tiny,Map.of());PlanClient.origin=new BlockPos(4,2,4);PlanClient.refresh();
ClientPlayNetworking.send(new PlanPayload(UUID.randomUUID(),PlanClient.origin,1,1,1,1,0,tiny.cells()));});
c.waitTicks(30);server.runOnServer(s->require(s.getPlayerList().getPlayers().getFirst().level().getBlockState(new BlockPos(4,2,4)).isAir(),"Survival spoofed paste leaves the world untouched"));
c.getInput().pressKey(InputConstants.KEY_K);c.waitForScreen(PlansScreen.class);
cockpit(c);
c.runOnClient(m->require(widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).noneMatch(b->key(b).equals("sanctuary.plans.paste")),"Survival UI only offers construction guidance"));
language(c,"fr_fr");c.takeScreenshot("plans072-survie-fr");
c.runOnClient(m->{PlanClient.layer=0;PlanClient.refresh();require(PlanClient.completed==0,"Free preview does not complete a block");});
@@ -97,15 +131,21 @@ public final class Plans072ClientChecks implements FabricClientGameTest {
c.runOnClient(m->m.gui.setScreen(null));
c.waitFor(m->m.player.distanceToSqr(4.5,1,3.5)<1&&m.player.getMainHandItem().is(Items.STONE),150);
c.getInput().lookAt(new BlockPos(4,1,4));c.waitTicks(3);
c.runOnClient(m->System.out.println("PLANS072 survival hit="+m.hitResult+" player="+m.player.position()+" origin="+PlanClient.origin+" held="+m.player.getMainHandItem()));
c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);
c.runOnClient(m->System.out.println("PLANS072 survival hit="+m.hitResult+" target="+(m.hitResult instanceof net.minecraft.world.phys.BlockHitResult h?h.getBlockPos()+" "+h.getDirection():"none")+" player="+m.player.position()+" origin="+PlanClient.origin+" held="+m.player.getMainHandItem()));
c.runOnClient(m->m.gameMode.useItemOn(m.player,net.minecraft.world.InteractionHand.MAIN_HAND,
new net.minecraft.world.phys.BlockHitResult(new Vec3(4.5,2,4.5),Direction.UP,new BlockPos(4,1,4),false)));
c.waitFor(m->PlanClient.completed==1,150);
server.runOnServer(s->require(s.getPlayerList().getPlayers().getFirst().getMainHandItem().getCount()==1,"Survival native placement consumes exactly one block"));
c.runOnClient(m->{PlanClient.clear();m.gui.setScreen(new PlansScreen(null));});c.takeScreenshot("plans072-create-fr");
c.runOnClient(m->PlanClient.clear());
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setGameMode(GameType.CREATIVE);p.getAbilities().flying=true;p.onUpdateAbilities();p.teleportTo(.5,10,.5);});
c.waitFor(m->m.player.distanceToSqr(.5,10,.5)<1,100);
server.runOnServer(s->Metatable.open(s.getPlayerList().getPlayers().getFirst(),new BlockPos(0,9,2)));c.waitForScreen(PlansScreen.class);c.takeScreenshot("plans072-create-fr");
language(c,"en_us");
System.out.println("PLANS072 passed: native models + UV textures + active palette, creation widgets, 32-block statue, rotations, materials, gzip export, colored preview, real creative network paste, survival spoof rejection and paid native placement, FR/EN.");
System.out.println("PLANS072 passed: native models + UV textures + active palette, creation widgets, 32-block statue, rotations, materials, gzip export, colored preview, creative confirmation/cancel, statue/machine/building construction, mode change rejection, survival spoof rejection and paid native placement, FR/EN.");
}
}
private static void cockpit(ClientGameTestContext c){c.getInput().holdKey(InputConstants.KEY_K);c.waitTicks(12);c.getInput().releaseKey(InputConstants.KEY_K);c.waitForScreen(PlansScreen.class);}
private static void configure(Screen screen){button(screen,"sanctuary.plans.tab.statue").onPress(enter());button(screen,"sanctuary.plans.configure").onPress(enter());widgets(screen).filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow().setValue("minecraft:creeper");}
private static void language(ClientGameTestContext c,String id){
var reload=c.computeOnClient(m->{m.getLanguageManager().setSelected(id);return m.reloadResourcePacks();});
c.waitFor(m->reload.isDone(),600);reload.join();c.waitFor(m->m.gui.overlay()==null,400);c.waitTicks(8);
@@ -0,0 +1,165 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.packs.PackSelectionScreen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.repository.PackCompatibility;
/** Native screen/export, complete snapshot, real resource precedence and preservation of edits. */
public final class ResourceTemplate121ClientChecks implements FabricClientGameTest {
private static final String BUILTIN = "sanctuary:textures";
private static final String PERSONAL = "file/" + ResourcePackTemplate.FOLDER;
private static final String WRENCH = "sanctuary:textures/item/golden_wrench.png";
private static final String WRENCH_PATH = "assets/sanctuary/textures/item/golden_wrench.png";
private static final String WRENCH_SHA = "a97570908db75caf8e6fc2f5ecabf12c54fd99bfea7be634df66c4310dca22af";
public void runTest(ClientGameTestContext c) {
try { run(c); } catch (IOException error) { throw new AssertionError(error); }
}
private void run(ClientGameTestContext c) throws IOException {
var previous = c.computeOnClient(m -> m.gui.screen());
var packDirectory = c.computeOnClient(Minecraft::getResourcePackDirectory);
var selected = c.computeOnClient(m -> List.copyOf(m.getResourcePackRepository().getSelectedIds()));
var target = packDirectory.resolve(ResourcePackTemplate.FOLDER);
check(!Files.exists(target), "Fresh disposable client directory");
c.runOnClient(m -> {
check(source(m).equals(BUILTIN), "Built-in pack active");
check(hash(resource(m, WRENCH)).equals(WRENCH_SHA), "Exact creator PNG active");
m.getWindow().setWindowed(1280, 800);
});
for (int scale : new int[]{2, 3}) for (String language : List.of("fr_fr", "en_us")) {
c.runOnClient(m -> {
m.options.guiScale().set(scale); m.resizeGui();
m.getLanguageManager().setSelected(language);
m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
screen(m, packDirectory);
var button = button(m, "customize");
check(button.getX() >= 0 && button.getRight() <= m.gui.screen().width, "Template button fits width");
check(button.getY() > 60 && button.getBottom() < m.gui.screen().height, "Template button fits height");
var done = m.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> key(b, "gui.done")).findFirst().orElseThrow();
check(done.getY() >= button.getBottom() + 4, "Separate native footer row");
check(m.font.width(button.getMessage()) < button.getWidth() - 8, "Localized label fits");
});
c.waitTicks(3); c.takeScreenshot("template121-create-" + language + "-" + scale);
}
c.runOnClient(m -> button(m, "customize").onPress(new KeyEvent(InputConstants.KEY_RETURN, 0, 0)));
c.waitFor(m -> m.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.anyMatch(b -> b.active && key(b, "sanctuary.resource_pack.open_copy")), 1200);
c.waitFor(m -> m.getResourcePackRepository().getAvailableIds().contains(PERSONAL), 1200);
c.runOnClient(m -> {
check(List.copyOf(m.getResourcePackRepository().getSelectedIds()).equals(selected), "Creating never changes selection");
check(m.getResourcePackRepository().getPack(PERSONAL).getCompatibility() == PackCompatibility.COMPATIBLE,
"Template format accepted by exact Minecraft version");
});
var mod = FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow();
Map<String, String> expected = new TreeMap<>();
merge(expected, mod.findPath("assets").orElseThrow(), "assets/");
merge(expected, mod.findPath("resourcepacks/textures/assets").orElseThrow(), "assets/");
merge(expected, mod.findPath("resourcepacks/template").orElseThrow(), "");
expected.put("pack.png", hash(Files.readAllBytes(mod.findPath("resourcepacks/textures/pack.png").orElseThrow())));
check(snapshot(target).equals(expected), "Complete byte-identical snapshot with built-in precedence");
Files.writeString(packDirectory.getParent().resolve("template121-export-sha256.json"),
new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(expected));
check(expected.containsKey("README.md") && expected.containsKey("LICENSE_sanctuary")
&& expected.containsKey("THIRD_PARTY_NOTICES.md"), "Editable copy includes guide and credits");
check(expected.keySet().stream().noneMatch(p -> p.startsWith("data/") || p.endsWith(".class")), "Client assets only");
c.waitTicks(4); c.takeScreenshot("template121-copy-ready");
c.runOnClient(m -> m.gui.setScreen(new Screen(Component.literal("Golden wrench")) {
@Override public void extractRenderState(GuiGraphicsExtractor g, int x, int y, float delta) {
g.fill(0, 0, width, height, 0xff292929);
g.centeredText(font, Component.translatable("item.sanctuary.golden_wrench"), width / 2, 55, 0xffffffff);
g.blit(RenderPipelines.GUI_TEXTURED, Identifier.parse(WRENCH), width / 2 - 48, 85, 0, 0, 96, 96, 16, 16, 16, 16);
}
}));
c.waitTicks(4); c.takeScreenshot("template121-wrench-exact-texture");
byte[] replacement = c.computeOnClient(m -> resource(m, "minecraft:textures/item/golden_pickaxe.png"));
Files.write(target.resolve(WRENCH_PATH), replacement);
Files.writeString(target.resolve("my-notes.txt"), "Personal edits must survive all subsequent exports.");
var edited = snapshot(target);
check(ResourcePackTemplate.create(packDirectory).equals(target.toAbsolutePath().normalize()), "Existing copy returned");
check(snapshot(target).equals(edited), "Second export preserves every edited byte and extra file");
c.runOnClient(m -> {
var repo = m.getResourcePackRepository(); repo.addPack(PERSONAL); m.options.updateResourcePacks(repo);
});
c.waitFor(m -> source(m).equals(PERSONAL) && m.gui.overlay() == null, 1200);
c.runOnClient(m -> {
check(Arrays.equals(resource(m, WRENCH), replacement), "Personal edit overrides built-in texture");
var repo = m.getResourcePackRepository(); repo.reload(); m.options.loadSelectedResourcePacks(repo);
check(repo.getSelectedIds().contains(PERSONAL), "Personal activation persists after rediscovery");
screen(m, packDirectory); button(m, "open_copy");
});
c.waitTicks(3); c.takeScreenshot("template121-personal-active");
var reload = c.computeOnClient(Minecraft::reloadResourcePacks);
c.waitFor(m -> reload.isDone() && m.gui.overlay() == null, 1200); reload.join();
check(snapshot(target).equals(edited), "Reload never writes personal assets");
c.runOnClient(m -> {
check(Arrays.equals(resource(m, WRENCH), replacement), "Reload still uses personal PNG");
var repo = m.getResourcePackRepository(); repo.removePack(PERSONAL); m.options.updateResourcePacks(repo);
});
c.waitFor(m -> source(m).equals(BUILTIN) && m.gui.overlay() == null, 1200);
c.runOnClient(m -> {
check(hash(resource(m, WRENCH)).equals(WRENCH_SHA), "Disabling restores creator texture");
var repo = m.getResourcePackRepository(); repo.reload(); m.options.loadSelectedResourcePacks(repo);
check(!repo.getSelectedIds().contains(PERSONAL), "Explicit opt-out retained");
});
Path collision = Files.createTempDirectory(packDirectory.getParent(), "template121-collision-");
Files.writeString(collision.resolve(ResourcePackTemplate.FOLDER), "Do not replace this file");
try { ResourcePackTemplate.create(collision); throw new AssertionError("Existing file must be refused"); }
catch (IOException expectedError) { check(Files.readString(collision.resolve(ResourcePackTemplate.FOLDER)).equals("Do not replace this file"), "Collision leaves file intact"); }
Path datapacks = Files.createTempDirectory(packDirectory.getParent(), "template121-datapacks-");
c.runOnClient(m -> {
screen(m, datapacks);
check(m.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.noneMatch(b -> key(b, "sanctuary.resource_pack.customize") || key(b, "sanctuary.resource_pack.open_copy")), "No button in datapack selector");
m.gui.setScreen(previous);
});
System.out.println("TEMPLATE121_PASS: " + expected.size() + " exact files; native button FR/EN at scales 2/3; complete copy; edits and selection preserved; actual override, reload and opt-out; collision safe; datapacks untouched");
}
private static void screen(Minecraft m, Path directory) {
m.gui.setScreen(new PackSelectionScreen(m.getResourcePackRepository(), repo -> {}, directory, Component.translatable("resourcePack.title")));
}
private static Button button(Minecraft m, String suffix) {
return m.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> key(b, "sanctuary.resource_pack." + suffix)).findFirst().orElseThrow();
}
private static boolean key(Button b, String key) { return b.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key); }
private static String source(Minecraft m) { return m.getResourceManager().getResource(Identifier.parse(WRENCH)).orElseThrow().sourcePackId(); }
private static byte[] resource(Minecraft m, String name) {
try (var in = m.getResourceManager().getResource(Identifier.parse(name)).orElseThrow().open()) { return in.readAllBytes(); }
catch (IOException error) { throw new AssertionError(error); }
}
private static String hash(byte[] bytes) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
catch (java.security.NoSuchAlgorithmException error) { throw new AssertionError(error); }
}
private static Map<String, String> snapshot(Path root) throws IOException { Map<String, String> result = new TreeMap<>(); merge(result, root, ""); return result; }
private static void merge(Map<String, String> result, Path root, String prefix) throws IOException {
try (var files = Files.walk(root)) { for (Path file : files.filter(Files::isRegularFile).toList()) result.put(prefix + root.relativize(file).toString().replace('\\', '/'), hash(Files.readAllBytes(file))); }
}
private static void check(boolean value, String message) { if (!value) throw new AssertionError(message); }
}
@@ -0,0 +1,82 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.companion.CompanionService;
import fr.koka.sanctuary.companion.CompanionWorld;
import net.minecraft.client.KeyMapping;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.CustomData;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.Vec3;
/** Imported files never become world data; rejected imports survive safe world transitions. */
public final class Schematics100ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
var bad=PlanClient.directory().resolve("beta100-bad.schem");var good=PlanClient.directory().resolve("beta100-good.schem");
var table=new BlockPos(3,1,3);
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
try{
java.nio.file.Files.createDirectories(bad.getParent());write(bad,"missing_mod:block");write(good,"minecraft:stone");
byte[] original=java.nio.file.Files.readAllBytes(bad);
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Schematics100 "+UUID.randomUUID());s.setSeed("100");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
saved=world.getWorldSave();c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(.5,1,.5);for(var q:fr.koka.sanctuary.multiblock.Metatable.members(table))p.level().setBlockAndUpdate(q,Blocks.CRAFTING_TABLE.defaultBlockState());check(fr.koka.sanctuary.multiblock.Metatable.assemble(p,table),"Workshop");});
c.waitFor(m->fr.koka.sanctuary.multiblock.Metatable.state(m.level,table,true)==1,100);
world.getServer().runOnServer(s->fr.koka.sanctuary.multiblock.Metatable.open(s.getPlayerList().getPlayers().getFirst(),table));c.waitForScreen(PlansScreen.class);
c.runOnClient(m->PlanClient.load(bad));c.waitFor(m->!PlanClient.busy,100);
c.runOnClient(m->{check(m.gui.screen() instanceof PlansScreen&&PlanClient.plan==null,"Invalid import stays in workshop");check(PlanClient.status.getString().contains("missing_mod:block"),"Error names incompatible block");});
c.takeScreenshot("schematics100-rejected-import");
c.runOnClient(m->PlanClient.load(good));c.waitFor(m->!PlanClient.busy&&PlanClient.plan!=null&&m.gui.screen()==null,100);
check(Arrays.equals(original,java.nio.file.Files.readAllBytes(bad)),"Rejected file unchanged");
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);
c.runOnClient(m->check(PlanClient.plan==null&&!PlanClient.busy,"No stale plan after reload"));
world.getServer().runOnServer(s->check(fr.koka.sanctuary.multiblock.Metatable.state(s.overworld(),table,true)==1,"Same world and table survived reload"));
}
intro.set(true);
try(var second=c.worldBuilder().adjustSettings(s->{s.setName("Schematics100 second "+UUID.randomUUID());s.setSeed("101");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
c.runOnClient(m->check(PlanClient.plan==null&&!MetatableClient.available(),"Independent second world opens without stale plan/session"));
}
}catch(java.io.IOException e){throw new AssertionError(e);}
finally{try{java.nio.file.Files.deleteIfExists(bad);java.nio.file.Files.deleteIfExists(good);}catch(java.io.IOException ignored){}}
System.out.println("SCHEMATICS100_PASS rejected asynchronous import, useful diagnostic, valid import, save/reopen, source bytes preserved");
}
private static void write(java.nio.file.Path path,String id)throws java.io.IOException{
var root=new CompoundTag();root.putInt("Version",3);root.putInt("DataVersion",net.minecraft.SharedConstants.getCurrentVersion().dataVersion().version());root.putShort("Width",(short)1);root.putShort("Height",(short)1);root.putShort("Length",(short)1);
var palette=new CompoundTag();palette.putInt(id,0);var blocks=new CompoundTag();blocks.put("Palette",palette);blocks.putByteArray("Data",new byte[]{0});root.put("Blocks",blocks);var outer=new CompoundTag();outer.put("Schematic",root);net.minecraft.nbt.NbtIo.writeCompressed(outer,path);
}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,105 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.core.*;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.storage.TagValueInput;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.phys.*;
/** Checks emitted render geometry, not a duplicate of the placement implementation. */
final class Sculpture113ClientChecks {
static void placementContexts(ServerPlayer player){
for(var pos:List.of(new BlockPos(2,0,-2),new BlockPos(-2,0,2))){
for(var face:List.of(Direction.UP,Direction.DOWN)){
for(double x:new double[]{.25,.5,.75})for(double z:new double[]{.25,.5,.75}){
var hit=new Vec3(pos.getX()+x,pos.getY()+(face==Direction.UP?1:0),pos.getZ()+z);
var context=new BlockPlaceContext(player,InteractionHand.MAIN_HAND,new ItemStack(Statuary.ITEM),new BlockHitResult(hit,face,pos,false));
check(Statuary.BLOCK.getStateForPlacement(context).getValue(Statuary.ANCHOR)==SculptureAnchor.CENTER,"Middle square including boundaries selects center on top/bottom faces");
}
}
for(var edge:Direction.Plane.HORIZONTAL){
for(double offset:new double[]{.25-1e-6,.25+1e-6}){
var hit=new Vec3(pos.getX()+.5+edge.getStepX()*offset,pos.getY()+1,pos.getZ()+.5+edge.getStepZ()*offset);
var context=new BlockPlaceContext(player,InteractionHand.MAIN_HAND,new ItemStack(Statuary.ITEM),new BlockHitResult(hit,Direction.UP,pos,false));
var expected=offset<.25?SculptureAnchor.CENTER:SculptureAnchor.of(edge);
check(Statuary.BLOCK.getStateForPlacement(context).getValue(Statuary.ANCHOR)==expected,"Center boundary switches to the aimed edge");
}
var top=new Vec3(pos.getX()+.5+edge.getStepX()*.4,pos.getY()+1,pos.getZ()+.5+edge.getStepZ()*.4);
var context=new BlockPlaceContext(player,InteractionHand.MAIN_HAND,new ItemStack(Statuary.ITEM),new BlockHitResult(top,Direction.UP,pos,false));
var state=Statuary.BLOCK.getStateForPlacement(context);
check(context.getClickedPos().equals(pos.above())&&state.getValue(Statuary.ANCHOR)==SculptureAnchor.of(edge),"Top hit selects aimed edge at positive/negative coordinates");
var side=Vec3.atCenterOf(pos).add(edge.getStepX()*.5,0,edge.getStepZ()*.5);
context=new BlockPlaceContext(player,InteractionHand.MAIN_HAND,new ItemStack(Statuary.ITEM),new BlockHitResult(side,edge,pos,false));
check(Statuary.BLOCK.getStateForPlacement(context).getValue(Statuary.ANCHOR)==SculptureAnchor.of(edge.getOpposite()),"Side hit anchors against supporting block");
}
}
for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:List.of(SculptureAnchor.CENTER,SculptureAnchor.NORTH,SculptureAnchor.SOUTH,SculptureAnchor.EAST,SculptureAnchor.WEST)){
var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor);
for(var rotation:Rotation.values()){
var actual=state.rotate(rotation);
check(actual.getValue(Statuary.ANCHOR)==(anchor==SculptureAnchor.CENTER?anchor:SculptureAnchor.of(rotation.rotate(Direction.valueOf(anchor.name())))),"Structure rotates anchor independently of facing");
}
for(var mirror:Mirror.values()){
var actual=state.mirror(mirror);
check(actual.getValue(Statuary.ANCHOR)==(anchor==SculptureAnchor.CENTER?anchor:SculptureAnchor.of(mirror.mirror(Direction.valueOf(anchor.name())))),"Structure mirrors anchor independently of facing");
}
var legacy=net.minecraft.nbt.NbtUtils.writeBlockState(state);legacy.getCompoundOrEmpty("properties").remove("anchor");
var loaded=net.minecraft.nbt.NbtUtils.readBlockState(net.minecraft.core.registries.BuiltInRegistries.BLOCK,legacy);
check(loaded.getValue(Statuary.FACING)==facing&&loaded.getValue(Statuary.ANCHOR)==SculptureAnchor.CENTER,"Beta.111/112 state keeps facing and defaults anchor");
}
}
static void geometry(Minecraft client,String model){
// Every footprint, odd and even, through all orientations and saved anchor states.
for(int w=1;w<=16;w++)for(int d=1;d<=16;d++){
var sculpture=new VoxelSculpture(model,"Grid fixture",w,2,d,List.of(new VoxelSculpture.Cell(0,0,0,0xFFFF0000),new VoxelSculpture.Cell(w-1,1,d-1,0xFF0000FF)));
for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:SculptureAnchor.values())rendered(entity(client,sculpture,facing,anchor));
}
// Transparent margins in imported models must not leave a gap at the aimed edge.
var padded=new VoxelSculpture(model,"Padded fixture",13,2,15,List.of(new VoxelSculpture.Cell(2,0,3,-1),new VoxelSculpture.Cell(6,1,10,-1)));
for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:SculptureAnchor.values())rendered(entity(client,padded,facing,anchor));
// Colored markers identify forward motion after rotation, independent of the origin shift.
var sculpture=new VoxelSculpture(model,"Direction fixture",3,2,7,List.of(new VoxelSculpture.Cell(0,0,0,0xFFFF0000),new VoxelSculpture.Cell(0,0,6,0xFF0000FF),new VoxelSculpture.Cell(2,1,0,0xFF00FF00)));
for(var facing:Direction.Plane.HORIZONTAL){
var mesh=mesh(entity(client,sculpture,facing,SculptureAnchor.CENTER));
var red=mesh.stream().filter(q->q.color()==0xFFFF0000).findFirst().orElseThrow();var blue=mesh.stream().filter(q->q.color()==0xFF0000FF).findFirst().orElseThrow();
check(blue.x()-red.x()==facing.getStepX()*6/16f&&blue.z()-red.z()==facing.getStepZ()*6/16f,"Colored front faces gaze");
}
var itemMesh=new StatuaryRenderer.ItemRenderer().extractArgument(StatuaryEntity.item(sculpture));
check(itemMesh.equals(mesh(entity(client,sculpture,Direction.SOUTH,SculptureAnchor.CENTER))),"Item preview shares exact centered grid geometry");
System.out.println("SCULPTURE113_GEOMETRY_PASS 9216 footprints/orientations/anchors and colored direction markers");
}
private static StatuaryEntity entity(Minecraft client,VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor){
var entity=new StatuaryEntity(BlockPos.ZERO,Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor));entity.setLevel(client.level);
var data=new CompoundTag();data.put("sculpture",StatuaryEntity.encode(sculpture));entity.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING,client.level.registryAccess(),data));return entity;
}
private static List<StatuaryRenderer.Quad> mesh(StatuaryEntity entity){
var renderer=new StatuaryRenderer();var state=renderer.createRenderState();renderer.extractRenderState(entity,state,0,Vec3.ZERO,null);return state.mesh;
}
static void rendered(StatuaryEntity entity){
var mesh=mesh(entity);check(!mesh.isEmpty(),"Sculpture has visible geometry");
float minX=1,minZ=1,maxX=0,maxZ=0;
for(var q:mesh){
check(q.x()*16==Math.rint(q.x()*16)&&q.y()*16==Math.rint(q.y()*16)&&q.z()*16==Math.rint(q.z()*16),"Every rendered voxel is on the exact 1/16 grid");
check(q.x()>=0&&q.y()>=0&&q.z()>=0&&q.x()+1/16f<=1&&q.y()+1/16f<=1&&q.z()+1/16f<=1,"No voxel spills out of its block");
minX=Math.min(minX,q.x());maxX=Math.max(maxX,q.x()+1/16f);minZ=Math.min(minZ,q.z());maxZ=Math.max(maxZ,q.z()+1/16f);
}
var anchor=entity.getBlockState().getValue(Statuary.ANCHOR);
switch(anchor){
case NORTH -> check(minZ==0,"North edge flush");case SOUTH -> check(maxZ==1,"South edge flush");
case WEST -> check(minX==0,"West edge flush");case EAST -> check(maxX==1,"East edge flush");
case NORTH_WEST -> check(minX==0&&minZ==0,"North-west corner flush");case NORTH_EAST -> check(maxX==1&&minZ==0,"North-east corner flush");
case SOUTH_WEST -> check(minX==0&&maxZ==1,"South-west corner flush");case SOUTH_EAST -> check(maxX==1&&maxZ==1,"South-east corner flush");default -> {}
}
if(anchor==SculptureAnchor.CENTER||anchor==SculptureAnchor.NORTH||anchor==SculptureAnchor.SOUTH)check(Math.abs(minX-(1-maxX))<=1/16f,"Other axis centered to nearest whole voxel");
if(anchor==SculptureAnchor.CENTER||anchor==SculptureAnchor.EAST||anchor==SculptureAnchor.WEST)check(Math.abs(minZ-(1-maxZ))<=1/16f,"Other axis centered to nearest whole voxel");
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,84 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.plans.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.ItemContainerContents;
import net.minecraft.world.level.block.Blocks;
/** All registered block materials, native paid crafts, stale input rejection and pack reload. */
public final class Sculpture116ClientChecks implements FabricClientGameTest {
private static final BlockPos WORKSHOP=new BlockPos(2,1,2);
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
Path file=PlanClient.directory().resolve("sculpture116-"+UUID.randomUUID()+".glb");
try{
Files.createDirectories(file.getParent());Files.write(file,Statuary106ClientChecks.fixture(true));var asset=GlbModel.load(file);var source=VoxelSculpture.of(asset.id(),asset.name(),StatueVoxelizer.miniature(asset.faces()));
try{new SculpturePalette(List.of());throw new AssertionError("Empty palette accepted");}catch(IllegalArgumentException expected){}
try{new SculpturePalette(Collections.nCopies(257,-1));throw new AssertionError("Oversized palette accepted");}catch(IllegalArgumentException expected){}
try{new SculpturePalette(List.of(0));throw new AssertionError("Transparent color accepted");}catch(IllegalArgumentException expected){}
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;ItemStack hat;
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sculpture116 "+UUID.randomUUID());s.setSeed("116");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
saved=world.getWorldSave();c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.onUpdateAbilities();p.getInventory().clearContent();p.teleportTo(.5,1,.5);for(int x=-4;x<8;x++)for(int z=-4;z<8;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.level().setBlockAndUpdate(WORKSHOP,Statuary.WORKSHOP.defaultBlockState());Statuary.open(p,WORKSHOP);});
c.waitForScreen(ClayWorkshopScreen.class);c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();((ClayWorkshopScreen)m.gui.screen()).load(file);});
c.waitFor(m->!((ClayWorkshopScreen)m.gui.screen()).busy()&&((ClayWorkshopScreen)m.gui.screen()).selected()!=null,200);
c.runOnClient(m->{int count=0;for(var item:BuiltInRegistries.ITEM)if(item instanceof BlockItem){var stack=new ItemStack(item);check(ClayWorkshopMenu.material(stack),"Empty registered block accepted: "+item);var palette=SculptureMaterialSampler.sample(stack);check(palette!=null&&palette.colors().size()<=256&&palette.colors().stream().allMatch(v->(v>>>24)==255),"Bounded opaque palette: "+item);count++;}check(count>1000,"Whole block registry sampled");System.out.println("SCULPTURE116_PALETTES "+count+" registered block items");
var dark=new ItemStack(Items.SHULKER_BOX);dark.set(DataComponents.CONTAINER,ItemContainerContents.fromItems(List.of(new ItemStack(Items.DIAMOND))));check(!ClayWorkshopMenu.material(dark),"Filled container cannot lose contents");check(!ClayWorkshopMenu.material(new ItemStack(Items.DIAMOND)),"Non-block item refused");
check(source.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()==1,"Native paid crafts start from a uniform model");Sculpture117Checks.run(source);});
var rendered=new HashSet<VoxelSculpture>();
for(var item:List.of(Items.STONE,Items.OAK_PLANKS,Items.GOLD_BLOCK,Items.WOOL.pick(DyeColor.RED),Items.GLASS,Items.OAK_LEAVES,Items.CLAY,Items.DEEPSLATE)){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();var menu=(ClayWorkshopMenu)p.containerMenu;menu.getSlot(0).set(new ItemStack(item,2));check(menu.getSlot(1).getItem().isEmpty(),"Previous material result invalidates immediately");menu.broadcastChanges();});
var expected=c.computeOnClient(m->ClayWorkshopMenu.appearance(source,item,SculptureMaterialSampler.sample(new ItemStack(item))));rendered.add(expected);
c.waitFor(m->expected.equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())),180);
c.runOnClient(m->check(ItemStack.matches(((ClayWorkshopScreen)m.gui.screen()).preview(),m.player.containerMenu.getSlot(1).getItem()),"Preview equals server-owned result"));
check(StatuaryEntity.decode(StatuaryEntity.encode(expected)).equals(expected),"Palette colors persist in unchanged schema 1");
c.takeScreenshot("sculpture116-"+BuiltInRegistries.ITEM.getKey(item).getPath());
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.PICKUP,m.player));
c.waitFor(m->m.player.containerMenu.getCarried().is(Statuary.ITEM)&&m.player.containerMenu.getSlot(0).getItem().getCount()==1,100);
server.waitFor(s->{var menu=s.getPlayerList().getPlayers().getFirst().containerMenu;return menu.getCarried().is(Statuary.ITEM)&&menu.getSlot(0).getItem().getCount()==1;},100);
server.runOnServer(s->{var menu=(ClayWorkshopMenu)s.getPlayerList().getPlayers().getFirst().containerMenu;check(expected.equals(StatuaryEntity.sculpture(menu.getCarried()))&&menu.getSlot(0).getItem().getCount()==1,"Exactly one actual block consumed per sculpture");});
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,ClayWorkshopMenu.HOTBAR_START,0,ContainerInput.PICKUP,m.player));c.waitFor(m->m.player.containerMenu.getCarried().isEmpty(),100);server.waitFor(s->s.getPlayerList().getPlayers().getFirst().containerMenu.getCarried().isEmpty(),100);
}
check(rendered.size()>=7,"Distinct materials produce distinct sculptures");
c.waitTicks(6);var stone=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(Items.STONE)));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var menu=(ClayWorkshopMenu)p.containerMenu;menu.getSlot(0).set(new ItemStack(Items.DIAMOND_BLOCK));menu.select(p,source,Items.STONE,stone);check(menu.getSlot(1).getItem().isEmpty(),"Stale material palette rejected by server");});
var diamond=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(Items.DIAMOND_BLOCK)).apply(source));c.waitFor(m->diamond.equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())),180);
c.runOnClient(m->{var menu=(ClayWorkshopMenu)m.player.containerMenu;ClientPlayNetworking.send(new ClayWorkshopMenu.Select(menu.containerId,UUID.randomUUID(),source,Items.DIAMOND_BLOCK,stone));});c.waitTicks(6);
server.runOnServer(s->{var menu=(ClayWorkshopMenu)s.getPlayerList().getPlayers().getFirst().containerMenu;check(diamond.equals(StatuaryEntity.sculpture(menu.getSlot(1).getItem()))&&menu.getSlot(0).getItem().getCount()==1,"Forged menu token cannot change recipe or spend input");});
var before=c.computeOnClient(m->m.getModelManager().getBlockStateModelSet());var reload=c.computeOnClient(m->m.reloadResourcePacks());c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(8);
c.runOnClient(m->{check(before!=m.getModelManager().getBlockStateModelSet(),"Actual resource reload replaces sampling generation");check(diamond.equals(StatuaryEntity.sculpture(((ClayWorkshopScreen)m.gui.screen()).preview())),"Preview refreshes after pack reload");});
hat=SculptureHat117Checks.exercise(c,server,diamond);
System.out.println("SCULPTURE116_MATERIALS_PASS all block palettes, eight native paid materials, preview equality, shading, stale selections, input identity, token, palette bounds, containers and resource reload");
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);c.waitTicks(12);
SculptureHat117Checks.verifyEquipped(c,world.getServer(),hat);
System.out.println("SCULPTURE117_HAT_RECONNECT_PASS equipped sculpture survives native save and fresh client reconnect");
}
}catch(java.io.IOException e){throw new AssertionError(e);}finally{intro.set(false);try{Files.deleteIfExists(file);}catch(java.io.IOException ignored){}}
new Statuary106ClientChecks().runTest(c);
System.out.println("SCULPTURE116_PASS materials plus full workshop/hotbar, placement, anchoring, drops, reconnect, FR/EN and Metabli regression");
}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,59 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart;
import net.minecraft.core.*;
import net.minecraft.util.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
/** Regression for flat-color models, exact texture colors, deterministic grain and bounded CPU work. */
final class Sculpture117Checks {
static void run(VoxelSculpture source){
var cells=new ArrayList<VoxelSculpture.Cell>();
for(int z=0;z<16;z++)for(int y=0;y<16;y++)for(int x=0;x<16;x++)cells.add(new VoxelSculpture.Cell(x,y,z,0xFF808080));
var flat=new VoxelSculpture(source.modelId(),"Flat",16,16,16,cells);
var colors=new ArrayList<Integer>();colors.addAll(Collections.nCopies(64,0xFF223344));colors.addAll(Collections.nCopies(192,0xFFCCBBAA));
var weighted=new SculpturePalette(colors);var out=weighted.apply(flat);
long dark=out.cells().stream().filter(v->v.color()==0xFF223344).count();
check(dark>900&&dark<1150,"Uniform source retains sampled 25% dark / 75% light texture distribution");
check(out.cells().stream().allMatch(v->colors.contains(v.color())),"Never invent an averaged color");
var reversed=new ArrayList<>(flat.cells());Collections.reverse(reversed);
var shuffled=weighted.apply(new VoxelSculpture(flat.modelId(),flat.name(),16,16,16,reversed));
check(Arrays.equals(out.packed(),shuffled.cells().reversed().stream().flatMapToInt(v->java.util.stream.IntStream.of(v.key(),v.color())).toArray()),"Sampling is independent of cell iteration order");
check(out.equals(weighted.apply(flat))&&out.equals(StatuaryEntity.decode(StatuaryEntity.encode(out))),"Stable pattern and persisted final colors");
var gradient=new ArrayList<Integer>();for(int i=0;i<256;i++)gradient.add(0xFF000000|i<<16|i<<8|i);
var palette=new SculpturePalette(gradient);
var shaded=new VoxelSculpture(flat.modelId(),flat.name(),16,16,16,cells.stream().map(v->new VoxelSculpture.Cell(v.x(),v.y(),v.z(),v.x()<8?0xFF000000:0xFFFFFFFF)).toList());
var result=palette.apply(shaded);
check(result.cells().stream().filter(v->v.x()<8).allMatch(v->SculpturePalette.lightness(v.color())<.5),"Dark regions sample dark shades");
check(result.cells().stream().filter(v->v.x()>=8).allMatch(v->SculpturePalette.lightness(v.color())>=.5),"Light regions sample light shades");
check(result.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>200,"Grain remains inside source shading regions");
var single=new SculpturePalette(List.of(0xFF123456));check(single.apply(flat).cells().stream().allMatch(v->v.color()==0xFF123456),"Truly uniform block textures remain uniform");
for(var item:List.of(Items.STONE,Items.OAK_PLANKS,Items.COAL_ORE,Items.OAK_LOG)){
var sampled=SculptureMaterialSampler.sample(new ItemStack(item));var sculpture=sampled.apply(flat);
check(sculpture.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>2,"Actual texture grain on flat model: "+item);
check(sculpture.cells().stream().allMatch(v->sampled.colors().contains(v.color())),"Exact sampled pixels: "+item);
}
// Log tops and bark are separate native face materials; both must supply exact colors.
var mc=Minecraft.getInstance();var state=Blocks.OAK_LOG.defaultBlockState();var parts=new ArrayList<BlockStateModelPart>();
mc.getModelManager().getBlockStateModelSet().get(state).collectParts(RandomSource.create(state.getSeed(BlockPos.ZERO)),parts);
var bySprite=new HashMap<String,Set<Integer>>();
for(var part:parts)for(var face:Direction.values())for(var quad:part.getQuads(face)){
var sample=PlanMaterials.faceSample(state,quad.materialInfo());var set=bySprite.computeIfAbsent(quad.materialInfo().sprite().contents().name().toString(),k->new HashSet<>());
for(int pixel:sample.pixels().argb()){int color=ARGB.multiply(pixel,sample.tint());if((color>>>24)>=128)set.add(color|0xFF000000);}
}
check(bySprite.size()>=2,"Native log has distinct face textures");var sampled=SculptureMaterialSampler.sample(new ItemStack(Items.OAK_LOG));
var union=new HashSet<Integer>();bySprite.values().forEach(union::addAll);
check(sampled.colors().stream().allMatch(union::contains),"All palette colors belong to baked face textures");
for(var faceColors:bySprite.values())check(sampled.colors().stream().anyMatch(faceColors::contains),"Each log texture contributes pixels");
for(int i=0;i<100;i++)palette.apply(flat);
long[] times=new long[101];for(int i=0;i<times.length;i++){long start=System.nanoTime();var measured=palette.apply(flat);times[i]=System.nanoTime()-start;check(measured.cells().size()==4096,"Full bounded model");}
Arrays.sort(times);
System.out.printf(java.util.Locale.ROOT,"SCULPTURE117_PERF 4096 voxels median %.3f ms, p95 %.3f ms%n",times[50]/1e6,times[95]/1e6);
System.out.println("SCULPTURE117_GRAIN_PASS flat model, weighted exact pixels, dark/light regions, deterministic cells, persistence and native face materials");
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,161 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.TagValueInput;
import net.minecraft.world.phys.*;
import net.minecraft.world.phys.shapes.*;
/** Single-box selection/collision bounds and light propagation compared with the real sculpture mesh. */
public final class Sculpture122ClientChecks implements FabricClientGameTest {
private static final String MODEL="sanctuary:model/"+"1".repeat(64);
private static final BlockPos TARGET=new BlockPos(0,1,0),SKY=new BlockPos(10,2,2),CONTROL=new BlockPos(18,2,2);
private static final VoxelSculpture ARCH=arch();
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0));
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0));intro.set(false);
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sculpture122 "+UUID.randomUUID());s.setSeed("122");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
saved=world.getWorldSave();c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=player(s);var l=p.level();for(int x=-14;x<=21;x++)for(int z=-6;z<=9;z++)l.setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.teleportTo(.5,1,3.2);p.setYRot(0);p.getInventory().setItem(p.getInventory().getSelectedSlot(),StatuaryEntity.item(ARCH));p.inventoryMenu.broadcastFullState();
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"weather clear");
});
c.waitFor(m->m.player.distanceToSqr(.5,1,3.2)<.1&&m.player.getMainHandItem().is(Statuary.ITEM)&&m.level.getBlockState(TARGET.below()).is(Blocks.STONE),150);
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(.5,.999,.5)));c.waitTicks(3);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult hit&&hit.getBlockPos().equals(TARGET.below())&&hit.getDirection()==Direction.UP,"Cursor aims at placement support"));
c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);c.waitTicks(3);
server.runOnServer(s->System.out.println("SCULPTURE122_PLACED "+player(s).level().getBlockState(TARGET)+" "+player(s).level().getBlockEntity(TARGET)));
c.waitFor(m->m.level.getBlockEntity(TARGET) instanceof StatuaryEntity e&&ARCH.equals(e.sculpture()),150);
server.runOnServer(s->{physical(player(s).level(),TARGET);placementOverlap(player(s));});
c.runOnClient(m->{physical(m.level,TARGET);geometry(m);Sculpture113ClientChecks.geometry(m,MODEL);});
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(.0625,1.5,.5)));c.waitTicks(5);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult hit&&hit.getBlockPos().equals(TARGET),"Native cursor selects an occupied pillar"));
c.takeScreenshot("sculpture124-single-outline");
server.runOnServer(s->player(s).level().setBlockAndUpdate(TARGET.north(),Blocks.REDSTONE_BLOCK.defaultBlockState()));
c.waitFor(m->m.level.getBlockState(TARGET.north()).is(Blocks.REDSTONE_BLOCK),100);
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(.5,1.5,.5)));c.waitTicks(5);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult hit&&hit.getBlockPos().equals(TARGET),"Native cursor selects the sculpture bounds even through its visual hole"));
c.takeScreenshot("sculpture124-select-volume");
// A state edit and fresh entity payload must invalidate geometry on both sides.
server.runOnServer(s->{var l=player(s).level();l.setBlockAndUpdate(TARGET,l.getBlockState(TARGET).setValue(Statuary.FACING,Direction.WEST).setValue(Statuary.ANCHOR,SculptureAnchor.EAST));});
c.waitFor(m->m.level.getBlockState(TARGET).getValue(Statuary.FACING)==Direction.WEST,100);
server.runOnServer(s->{var l=player(s).level();var bounds=l.getBlockState(TARGET).getShape(l,TARGET).bounds();check(bounds.maxX==1&&bounds.getXsize()==.25,"Server shape rotates and moves to aimed edge");});
c.runOnClient(m->{var bounds=m.level.getBlockState(TARGET).getShape(m.level,TARGET).bounds();check(bounds.maxX==1&&bounds.getXsize()==.25,"Client shape follows changed blockstate");});
var small=new VoxelSculpture(MODEL,"Small",1,1,1,List.of(new VoxelSculpture.Cell(0,0,0,-1)));
server.runOnServer(s->place(player(s).level(),TARGET,small,Direction.WEST,SculptureAnchor.EAST));
c.waitFor(m->m.level.getBlockEntity(TARGET) instanceof StatuaryEntity e&&small.equals(e.sculpture()),100);
c.runOnClient(m->check(m.level.getBlockState(TARGET).getShape(m.level,TARGET).bounds().getYsize()==1/16d,"Changed block entity payload invalidates cached shape"));
server.runOnServer(s->{var l=player(s).level();place(l,TARGET,ARCH,Direction.SOUTH,SculptureAnchor.CENTER);l.setBlockAndUpdate(TARGET.north(),Blocks.AIR.defaultBlockState());
for(int x=8;x<=12;x++)for(int z=0;z<=4;z++)for(int y=3;y<=5;y++)place(l,new BlockPos(x,y,z),ARCH,Direction.SOUTH,SculptureAnchor.CENTER);
for(int x=16;x<=20;x++)for(int z=0;z<=4;z++)l.setBlockAndUpdate(new BlockPos(x,3,z),Blocks.STONE.defaultBlockState());
l.setBlockAndUpdate(new BlockPos(-8,2,0),Blocks.GLOWSTONE.defaultBlockState());place(l,new BlockPos(-7,2,0),ARCH,Direction.SOUTH,SculptureAnchor.CENTER);
});
server.waitFor(s->{var l=player(s).level();return l.getBrightness(LightLayer.SKY,SKY)==15&&l.getBrightness(LightLayer.SKY,CONTROL)<15&&l.getBrightness(LightLayer.BLOCK,new BlockPos(-6,2,0))==13;},200);
c.waitFor(m->m.level.getBrightness(LightLayer.SKY,SKY)==15&&m.level.getBrightness(LightLayer.SKY,CONTROL)<15&&m.level.getBrightness(LightLayer.BLOCK,new BlockPos(-6,2,0))==13,200);
server.runOnServer(s->{var l=player(s).level();for(int x=8;x<=12;x++)for(int z=0;z<=4;z++)for(int y=3;y<=5;y++)check(l.getBrightness(LightLayer.SKY,new BlockPos(x,y,z))==15,"All 75 sculptures keep full native skylight");});
c.runOnClient(m->{var e=(StatuaryEntity)m.level.getBlockEntity(new BlockPos(10,4,2));var renderer=new StatuaryRenderer();var state=renderer.createRenderState();renderer.extractRenderState(e,state,0,Vec3.ZERO,null);check(net.minecraft.util.LightCoordsUtil.sky(state.lightCoords)==15,"Actual renderer receives full skylight inside a group");});
// Visual crowd at ground level: no whole-block AO squares between the models.
server.runOnServer(s->{var p=player(s);for(int x=-3;x<=3;x++)for(int z=-5;z<=-2;z++)place(p.level(),new BlockPos(x,1,z),ARCH,Direction.from2DDataValue(Math.floorMod(x+z,4)),SculptureAnchor.CENTER);p.teleportTo(.5,3,5.5);p.getAbilities().flying=true;p.onUpdateAbilities();});
c.waitFor(m->m.player.distanceToSqr(.5,3,5.5)<.1,100);c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(.5,1,-3)));c.waitTicks(12);c.takeScreenshot("sculpture122-crowd-light");
// Transparent-to-light is not emissive: in a closed room sculpture faces stay dark.
server.runOnServer(s->{var l=player(s).level();for(int x=-13;x<=-9;x++)for(int y=1;y<=5;y++)for(int z=5;z<=9;z++)l.setBlockAndUpdate(new BlockPos(x,y,z),x==-13||x==-9||y==1||y==5||z==5||z==9?Blocks.STONE.defaultBlockState():Blocks.AIR.defaultBlockState());place(l,new BlockPos(-11,2,7),ARCH,Direction.SOUTH,SculptureAnchor.CENTER);});
c.waitFor(m->m.level.getBlockEntity(new BlockPos(-11,2,7)) instanceof StatuaryEntity&&m.level.getBrightness(LightLayer.SKY,new BlockPos(-11,2,7))==0&&m.level.getBrightness(LightLayer.BLOCK,new BlockPos(-11,2,7))==0,200);
c.runOnClient(m->{var renderer=new StatuaryRenderer();var state=renderer.createRenderState();renderer.extractRenderState((StatuaryEntity)m.level.getBlockEntity(new BlockPos(-11,2,7)),state,0,Vec3.ZERO,null);check(state.lightCoords==0,"No fullbright workaround: a dark room produces dark faces");});
System.out.println("SCULPTURE122_LIGHT_PASS: 75 stacked models sky=15; opaque control shadow; block light=13 across sculpture; dark room=0; shade brightness=1");
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&m.level.getBlockEntity(TARGET) instanceof StatuaryEntity e&&ARCH.equals(e.sculpture()),1200);
world.getServer().runOnServer(s->physical(player(s).level(),TARGET));c.runOnClient(m->physical(m.level,TARGET));
c.waitFor(m->m.level.getBrightness(LightLayer.SKY,SKY)==15,200);
System.out.println("SCULPTURE122_PASS: native placement, one-box outline including holes, single-box physical collisions, shared render geometry, cache refresh, light propagation and save/reconnect");
}
}
private static void placementOverlap(ServerPlayer player){
var pos=new BlockPos(3,1,0);var level=player.level();player.teleportTo(3.75,1,.5);player.setYRot(0);
var filled=new ArrayList<VoxelSculpture.Cell>();for(int x=0;x<16;x++)for(int y=0;y<16;y++)for(int z=0;z<16;z++)filled.add(new VoxelSculpture.Cell(x,y,z,-1));
var dense=new VoxelSculpture(MODEL,"Solid",16,16,16,filled);var stack=StatuaryEntity.item(dense);player.getInventory().setItem(player.getInventory().getSelectedSlot(),stack);
var hit=new BlockHitResult(new Vec3(3.01,1,.5),Direction.UP,pos.below(),false);
var blocked=((BlockItem)Statuary.ITEM).place(new BlockPlaceContext(player,InteractionHand.MAIN_HAND,stack,hit));
check(!blocked.consumesAction()&&level.getBlockState(pos).isAir()&&stack.getCount()==1,"Native placement refuses occupied voxels inside player without consuming sculpture");
player.teleportTo(3.5,1,.5);var pillars=new ArrayList<VoxelSculpture.Cell>();for(int y=0;y<16;y++)for(int x:new int[]{0,15})pillars.add(new VoxelSculpture.Cell(x,y,0,-1));
var hollow=new VoxelSculpture(MODEL,"Two pillars",16,16,1,pillars);stack=StatuaryEntity.item(hollow);player.getInventory().setItem(player.getInventory().getSelectedSlot(),stack);
var hollowBlocked=((BlockItem)Statuary.ITEM).place(new BlockPlaceContext(player,InteractionHand.MAIN_HAND,stack,hit));
check(!hollowBlocked.consumesAction()&&level.getBlockState(pos).isAir(),"Placement also rejects a player inside the enclosing box between separated pillars");player.teleportTo(3.75,1,.5);
var postCells=new ArrayList<VoxelSculpture.Cell>();for(int y=0;y<16;y++)for(int x=0;x<2;x++)for(int z=0;z<2;z++)postCells.add(new VoxelSculpture.Cell(x,y,z,-1));
var post=new VoxelSculpture(MODEL,"Post",2,16,2,postCells);stack=StatuaryEntity.item(post);player.getInventory().setItem(player.getInventory().getSelectedSlot(),stack);
var allowed=((BlockItem)Statuary.ITEM).place(new BlockPlaceContext(player,InteractionHand.MAIN_HAND,stack,hit));
check(allowed.consumesAction()&&level.getBlockState(pos).is(Statuary.BLOCK),"Native placement allows empty part of block beside player");
check(level.noCollision(player,player.getBoundingBox()),"Accepted sculpture does not intersect player");
level.setBlockAndUpdate(pos,Blocks.AIR.defaultBlockState());player.teleportTo(.5,1,3.2);player.getInventory().setItem(player.getInventory().getSelectedSlot(),StatuaryEntity.item(ARCH));player.inventoryMenu.broadcastFullState();
System.out.println("SCULPTURE122_PLACEMENT_PASS native item rejects occupied bounds including visual holes, allows space outside the box, preserves stack on rejection");
}
private static void physical(Level level,BlockPos pos){
var state=level.getBlockState(pos);var shape=state.getShape(level,pos);var collision=state.getCollisionShape(level,pos);
check(shape==collision&&shape.toAabbs().size()==1,"Selection and collision share one cached bounding box");check(shape==state.getShape(level,pos),"Selection queries reuse the cached box");check(shape.bounds().getZsize()==.25,"No invisible full-block depth");
check(level.clip(new ClipContext(new Vec3(.5,1.5,2),new Vec3(.5,1.5,-.1),ClipContext.Block.OUTLINE,ClipContext.Fluid.NONE,CollisionContext.empty())).getType()==HitResult.Type.BLOCK,"Outline ray selects the whole sculpture volume");
check(level.clip(new ClipContext(new Vec3(.0625,1.5,2),new Vec3(.0625,1.5,-.1),ClipContext.Block.COLLIDER,ClipContext.Fluid.NONE,CollisionContext.empty())).getType()==HitResult.Type.BLOCK,"Collision ray hits pillar");
check(level.clip(new ClipContext(new Vec3(.5,1.5,2),new Vec3(.5,1.5,-.1),ClipContext.Block.COLLIDER,ClipContext.Fluid.NONE,CollisionContext.empty())).getType()==HitResult.Type.BLOCK,"Physical collision uses the enclosing volume, including visual holes");
check(!level.noCollision(new AABB(.35,1.2,.35,.65,1.7,.65)),"Native collision query fills the inner volume instead of following tiny details");
check(!level.noCollision(new AABB(.02,1.2,.4,.1,1.7,.6)),"Native collision query blocks occupied pillar");
check(!state.isCollisionShapeFullBlock(level,pos)&&!state.isSolidRender()&&!state.canOcclude(),"Sculpture is not a solid occluding cube");
check(state.getOcclusionShape().isEmpty()&&state.propagatesSkylightDown()&&state.getLightDampening()==0&&state.getShadeBrightness(level,pos)==1,"Light metadata never reintroduces full-block occlusion");
check(!state.isSuffocating(level,pos)&&state.getLightEmission()==0,"Decorative shape neither suffocates nor emits light");
}
private static void geometry(Minecraft m){
var padded=new VoxelSculpture(MODEL,"Padded",13,5,15,List.of(new VoxelSculpture.Cell(2,2,3,-1),new VoxelSculpture.Cell(6,4,10,-1)));
var dense=new ArrayList<VoxelSculpture.Cell>();for(int x=0;x<16;x++)for(int y=0;y<16;y++)for(int z=0;z<16;z++)dense.add(new VoxelSculpture.Cell(x,y,z,-1));
var models=List.of(ARCH,padded,new VoxelSculpture(MODEL,"Dense",16,16,16,dense),new VoxelSculpture(MODEL,"One voxel",1,1,1,List.of(new VoxelSculpture.Cell(0,0,0,-1))));
int cases=0;for(var model:models)for(var direction:Direction.Plane.HORIZONTAL)for(var anchor:SculptureAnchor.values())for(var height:SculptureHeight.values()){
var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,direction).setValue(Statuary.ANCHOR,anchor).setValue(Statuary.HEIGHT,height);var entity=new StatuaryEntity(BlockPos.ZERO,state);entity.setLevel(m.level);load(entity,model,m.level);
var shape=entity.shape(state);check(shape==entity.shape(state),"Repeated geometry queries reuse the same shape");var boxes=shape.toAabbs();double volume=boxes.stream().mapToDouble(b->b.getXsize()*b.getYsize()*b.getZsize()).sum();check(boxes.size()==1&&volume>=model.cells().size()/4096d,"One box encloses all occupied cells");
var outline=entity.shape(state);check(outline==entity.shape(state),"Repeated outline queries reuse the cached box");
check(outline.toAabbs().size()==1&&outline.bounds().equals(shape.bounds()),"One outline box matches exact rendered extents for every orientation and anchor");
int[] edges={0};outline.forAllEdges((x1,y1,z1,x2,y2,z2)->edges[0]++);check(edges[0]==12,"Native outline emits only the 12 enclosing edges, independent of voxel count");
var renderer=new StatuaryRenderer();var render=renderer.createRenderState();renderer.extractRenderState(entity,render,0,Vec3.ZERO,null);
double minX=1,minY=1,minZ=1,maxX=0,maxY=0,maxZ=0;
for(var q:render.mesh){minX=Math.min(minX,q.x());minY=Math.min(minY,q.y());minZ=Math.min(minZ,q.z());maxX=Math.max(maxX,q.x()+1/16d);maxY=Math.max(maxY,q.y()+1/16d);maxZ=Math.max(maxZ,q.z()+1/16d);var center=new Vec3(q.x()+1/32d,q.y()+1/32d,q.z()+1/32d);check(boxes.stream().anyMatch(b->b.contains(center)),"Every rendered cell is physically present");
}
check(shape.bounds().equals(new AABB(minX,minY,minZ,maxX,maxY,maxZ)),"Single collision box exactly matches visible mesh extents");
cases++;
}
System.out.println("SCULPTURE124_BOUNDS_PASS "+cases+" cached single boxes agree with exact renderer extents; 12 outline edges each");
System.out.println("SCULPTURE122_GEOMETRY_PASS "+cases+" native bounding boxes agree with emitted mesh extents, plus cached 12-edge selection bounds for holes, padding, dense 4096 cells, single voxel, 4 directions, 9 anchors and 4 vertical modes");
}
private static void place(ServerLevel level,BlockPos pos,VoxelSculpture model,Direction facing,SculptureAnchor anchor){
var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor);level.setBlockAndUpdate(pos,state);var entity=(StatuaryEntity)level.getBlockEntity(pos);load(entity,model,level);entity.setChanged();level.sendBlockUpdated(pos,state,state,3);
}
private static void load(StatuaryEntity entity,VoxelSculpture model,Level level){var tag=new CompoundTag();tag.put("sculpture",StatuaryEntity.encode(model));entity.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING,level.registryAccess(),tag));}
private static VoxelSculpture arch(){var cells=new ArrayList<VoxelSculpture.Cell>();for(int x=0;x<16;x++)for(int y=0;y<16;y++)for(int z=0;z<4;z++)if(x<2||x>=14||y>=14)cells.add(new VoxelSculpture.Cell(x,y,z,0xffc99679));return new VoxelSculpture(MODEL,"Arch",16,16,4,cells);}
private static ServerPlayer player(MinecraftServer server){return server.getPlayerList().getPlayers().getFirst();}
private static void check(boolean value,String reason){if(!value)throw new AssertionError(reason);}
}
@@ -0,0 +1,118 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.nbt.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.*;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.TagValueInput;
import net.minecraft.world.phys.*;
/** Native clicks on all six support faces; additive saved-state compatibility and real renderer bounds. */
public final class Sculpture125ClientChecks implements FabricClientGameTest {
private static final BlockPos SUPPORT=new BlockPos(0,4,0);
private static final String MODEL="sanctuary:model/"+"2".repeat(64);
private static final VoxelSculpture STATUE=statue();
private static final SculptureAnchor[][] ANCHORS={{SculptureAnchor.NORTH_WEST,SculptureAnchor.WEST,SculptureAnchor.SOUTH_WEST},{SculptureAnchor.NORTH,SculptureAnchor.CENTER,SculptureAnchor.SOUTH},{SculptureAnchor.NORTH_EAST,SculptureAnchor.EAST,SculptureAnchor.SOUTH_EAST}};
private static final SculptureHeight[] HEIGHTS={SculptureHeight.BOTTOM,SculptureHeight.CENTER,SculptureHeight.TOP};
private record Spot(Direction face,int u,int v){
Vec3 hit(BlockPos p){double a=.1+.4*u,b=.1+.4*v;return switch(face){
case UP,DOWN->new Vec3(p.getX()+a,p.getY()+(face==Direction.UP?1:0),p.getZ()+b);
case NORTH,SOUTH->new Vec3(p.getX()+a,p.getY()+b,p.getZ()+(face==Direction.SOUTH?1:0));
case WEST,EAST->new Vec3(p.getX()+(face==Direction.EAST?1:0),p.getY()+b,p.getZ()+a);
};}
SculptureAnchor anchor(){return switch(face){case UP,DOWN->ANCHORS[u][v];case NORTH->ANCHORS[u][2];case SOUTH->ANCHORS[u][0];case EAST->ANCHORS[0][u];case WEST->ANCHORS[2][u];};}
SculptureHeight height(){return face==Direction.UP?SculptureHeight.BOTTOM:face==Direction.DOWN?SculptureHeight.TOP:HEIGHTS[v];}
}
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);var enter=new KeyEvent(InputConstants.KEY_RETURN,0,0);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter);intro.set(false);
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
var persistent=new HashMap<BlockPos,BlockState>();
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sculpture125 "+UUID.randomUUID());s.setSeed("125");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=player(s);var l=p.level();for(int x=-8;x<=8;x++)for(int z=-8;z<=8;z++)l.setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.getAbilities().flying=true;p.onUpdateAbilities();p.getInventory().setItem(p.getInventory().getSelectedSlot(),StatuaryEntity.item(STATUE));p.inventoryMenu.broadcastFullState();
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"weather clear");contexts(p);legacy(p);Sculpture113ClientChecks.placementContexts(p);l.setBlockAndUpdate(SUPPORT,Blocks.STONE.defaultBlockState());
});
c.waitFor(m->m.player.getMainHandItem().is(Statuary.ITEM)&&m.level.getBlockState(SUPPORT).is(Blocks.STONE),150);
int clicks=0;
for(var face:Direction.values()){
Vec3 eye= switch(face){case UP->new Vec3(.5,6,3);case DOWN->new Vec3(.5,1.3,2.5);case NORTH->new Vec3(.5,3,-2.2);case SOUTH->new Vec3(.5,3,3.2);case EAST->new Vec3(3.2,3,.5);case WEST->new Vec3(-2.2,3,.5);};
server.runOnServer(s->{var p=player(s);p.teleportTo(eye.x,eye.y,eye.z);p.setDeltaMovement(Vec3.ZERO);p.getAbilities().flying=true;p.onUpdateAbilities();});c.waitFor(m->m.player.distanceToSqr(eye)<.05,100);c.runOnClient(m->{m.player.getAbilities().flying=true;m.player.setDeltaMovement(Vec3.ZERO);m.player.onUpdateAbilities();});c.waitTicks(10);
for(int u=0;u<3;u++)for(int v=0;v<3;v++){
var spot=new Spot(face,u,v);var target=SUPPORT.relative(face);
server.runOnServer(s->{for(var direction:Direction.values())player(s).level().setBlockAndUpdate(SUPPORT.relative(direction),Blocks.AIR.defaultBlockState());});
c.waitFor(m->m.level.getBlockState(target).isAir(),100);
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,spot.hit(SUPPORT)));c.waitTicks(3);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult h&&h.getBlockPos().equals(SUPPORT)&&h.getDirection()==face,"Native cursor hits requested support face "+spot+" instead of "+m.hitResult+" at "+m.hitResult.getLocation()+" eye "+m.player.getEyePosition()+" flying "+m.player.getAbilities().flying));
var facing=c.computeOnClient(m->{System.out.println("SCULPTURE125_AIM "+spot+" actual "+m.hitResult.getLocation()+" eye "+m.player.getEyePosition());return m.player.getDirection();});c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);c.waitTicks(3);
c.waitFor(m->m.level.getBlockEntity(target) instanceof StatuaryEntity e&&STATUE.equals(e.sculpture()),100);
server.runOnServer(s->{var l=player(s).level();var state=l.getBlockState(target);check(state.getValue(Statuary.ANCHOR)==spot.anchor()&&state.getValue(Statuary.HEIGHT)==spot.height()&&state.getValue(Statuary.FACING)==facing,"Server uses aimed position and horizontal gaze for "+spot+" got "+state+" expected facing "+facing);});
c.runOnClient(m->{var e=(StatuaryEntity)m.level.getBlockEntity(target);renderBounds(e);});
if(u==0&&v==2)c.takeScreenshot("sculpture125-"+face.getSerializedName());clicks++;
}
}
System.out.println("SCULPTURE125_CLICKS_PASS "+clicks+" real placements: all six faces, every corner/edge/center and supported heights");
c.runOnClient(m->Sculpture125ParticleChecks.exercise(m,SUPPORT.relative(Direction.EAST)));
Sculpture125WorkshopChecks.exercise(c,server);
server.runOnServer(s->{var l=player(s).level();for(var direction:Direction.values())l.setBlockAndUpdate(SUPPORT.relative(direction),Blocks.AIR.defaultBlockState());for(int x=0;x<3;x++)for(int y=0;y<3;y++){
var pos=new BlockPos(x*2-2,y+1,0);l.setBlockAndUpdate(pos.north(),Blocks.STONE.defaultBlockState());var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,Direction.SOUTH).setValue(Statuary.ANCHOR,ANCHORS[x][0]).setValue(Statuary.HEIGHT,HEIGHTS[y]);place(l,pos,state);persistent.put(pos,state);
}player(s).teleportTo(.5,2,5);});
c.waitFor(m->m.player.distanceToSqr(.5,2,5)<.05,100);c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(.5,2.5,.5)));c.waitTicks(8);c.takeScreenshot("sculpture125-wall-placements");saved=world.getWorldSave();
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);world.getServer().runOnServer(s->{for(var entry:persistent.entrySet()){var state=player(s).level().getBlockState(entry.getKey());check(state==entry.getValue(),"New anchors and heights persist exactly");check(STATUE.equals(((StatuaryEntity)player(s).level().getBlockEntity(entry.getKey())).sculpture()),"Model data persists unchanged");}});
c.waitFor(m->persistent.keySet().stream().allMatch(p->m.level.getBlockEntity(p) instanceof StatuaryEntity e&&STATUE.equals(e.sculpture())),150);c.runOnClient(m->persistent.keySet().forEach(p->renderBounds((StatuaryEntity)m.level.getBlockEntity(p))));
}
new Sculpture122ClientChecks().runTest(c);
System.out.println("SCULPTURE125_PASS new placement gestures, native picking, collision/render agreement, legacy states and saved new positions");
}
private static void contexts(ServerPlayer p){int count=0;
for(var pos:List.of(new BlockPos(5,3,5),new BlockPos(-5,3,-5))){p.level().setBlockAndUpdate(pos,Blocks.STONE.defaultBlockState());
for(int turn=0;turn<4;turn++){p.setYRot(turn*90);for(var face:Direction.values())for(int u=0;u<3;u++)for(int v=0;v<3;v++){
var spot=new Spot(face,u,v);var context=new BlockPlaceContext(p,InteractionHand.MAIN_HAND,StatuaryEntity.item(STATUE),new BlockHitResult(spot.hit(pos),face,pos,false));var state=Statuary.BLOCK.getStateForPlacement(context);
check(context.getClickedPos().equals(pos.relative(face))&&state.getValue(Statuary.ANCHOR)==spot.anchor()&&state.getValue(Statuary.HEIGHT)==spot.height()&&state.getValue(Statuary.FACING)==p.getDirection(),"Native placement context agrees at signed coordinates: "+spot);count++;
}}p.level().setBlockAndUpdate(pos,Blocks.AIR.defaultBlockState());
}
check(Statuary.BLOCK.getStateDefinition().getPossibleStates().size()==144,"108 placement states plus 36 legacy-height states");
for(var anchor:SculptureAnchor.values())for(var height:SculptureHeight.values()){
var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.ANCHOR,anchor).setValue(Statuary.HEIGHT,height);var box=SculptureGeometry.shape(STATUE,Direction.SOUTH,anchor,height).bounds();
for(var rotation:Rotation.values()){var rotated=state.rotate(rotation);check(rotated.getValue(Statuary.HEIGHT)==height,"Yaw rotation retains height");var b=SculptureGeometry.shape(STATUE,rotated.getValue(Statuary.FACING),rotated.getValue(Statuary.ANCHOR),height).bounds();double ex=box.getCenter().x-.5,ez=box.getCenter().z-.5;double tx=switch(rotation){case NONE->ex;case CLOCKWISE_90->-ez;case CLOCKWISE_180->-ex;case COUNTERCLOCKWISE_90->ez;};double tz=switch(rotation){case NONE->ez;case CLOCKWISE_90->ex;case CLOCKWISE_180->-ez;case COUNTERCLOCKWISE_90->-ex;};check(Math.abs((b.getCenter().x-.5)-tx)<=1/16d&&Math.abs((b.getCenter().z-.5)-tz)<=1/16d&&b.minY==box.minY,"Rotated anchor follows native structure rotation without half voxels");}
for(var mirror:Mirror.values()){var mirrored=state.mirror(mirror);var b=SculptureGeometry.shape(STATUE,mirrored.getValue(Statuary.FACING),mirrored.getValue(Statuary.ANCHOR),height).bounds();double x=mirror==Mirror.FRONT_BACK?1-box.getCenter().x:box.getCenter().x,z=mirror==Mirror.LEFT_RIGHT?1-box.getCenter().z:box.getCenter().z;check(Math.abs(b.getCenter().x-x)<=1/16d&&Math.abs(b.getCenter().z-z)<=1/16d&&b.minY==box.minY,"Mirrored corner and vertical placement follow native structure mirror");}
}
System.out.println("SCULPTURE125_CONTEXT_PASS "+count+" contexts, signed coordinates, four headings, rotations/mirrors and 144 valid states");
}
private static void legacy(ServerPlayer p){int n=0;
for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:List.of(SculptureAnchor.CENTER,SculptureAnchor.NORTH,SculptureAnchor.SOUTH,SculptureAnchor.WEST,SculptureAnchor.EAST)){
var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor).setValue(Statuary.HEIGHT,SculptureHeight.TOP);var nbt=NbtUtils.writeBlockState(state);nbt.getCompoundOrEmpty("properties").remove("vertical_anchor");var restored=NbtUtils.readBlockState(BuiltInRegistries.BLOCK,nbt);
check(restored.getValue(Statuary.HEIGHT)==SculptureHeight.ORIGINAL&&restored.getValue(Statuary.ANCHOR)==anchor&&restored.getValue(Statuary.FACING)==facing,"Old NBT keeps orientation and anchor and defaults original Y");var b=SculptureGeometry.shape(STATUE,facing,anchor,restored.getValue(Statuary.HEIGHT)).bounds();check(b.minY==2/16d&&b.maxY==10/16d,"Legacy vertical padding is preserved exactly");n++;
}System.out.println("SCULPTURE125_LEGACY_PASS "+n+" old blockstates retain exact placements without rewriting payloads");
}
private static void renderBounds(StatuaryEntity entity){var renderer=new StatuaryRenderer();var render=renderer.createRenderState();renderer.extractRenderState(entity,render,0,Vec3.ZERO,null);double x0=1,y0=1,z0=1,x1=0,y1=0,z1=0;for(var q:render.mesh){x0=Math.min(x0,q.x());y0=Math.min(y0,q.y());z0=Math.min(z0,q.z());x1=Math.max(x1,q.x()+1/16d);y1=Math.max(y1,q.y()+1/16d);z1=Math.max(z1,q.z()+1/16d);}var bounds=entity.shape(entity.getBlockState()).bounds();check(bounds.equals(new AABB(x0,y0,z0,x1,y1,z1)),"Native single box follows actual rendered offsets on all three axes");Sculpture113ClientChecks.rendered(entity);}
private static void place(ServerLevel level,BlockPos pos,BlockState state){level.setBlockAndUpdate(pos,state);var e=(StatuaryEntity)level.getBlockEntity(pos);var data=new CompoundTag();data.put("sculpture",StatuaryEntity.encode(STATUE));e.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING,level.registryAccess(),data));e.setChanged();level.sendBlockUpdated(pos,state,state,3);}
private static VoxelSculpture statue(){var cells=new ArrayList<VoxelSculpture.Cell>();for(int y=2;y<10;y++)for(int x=y<6?2:1;x<=(y<6?4:5);x++)for(int z=y<6?1:0;z<=(y<6?3:4);z++)cells.add(new VoxelSculpture.Cell(x,y,z,y<6?0xff44aabb:0xffddb88b));return new VoxelSculpture(MODEL,"Placement statue",7,10,5,cells);}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static void check(boolean ok,String reason){if(!ok)throw new AssertionError(reason);}
}
@@ -0,0 +1,36 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.plans.*;
import java.lang.reflect.Field;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.particle.*;
import net.minecraft.core.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
final class Sculpture125ParticleChecks {
static void exercise(Minecraft mc,BlockPos pos){
var entity=(StatuaryEntity)mc.level.getBlockEntity(pos);var state=entity.getBlockState();
var render=new StatuaryRenderer();var rs=render.createRenderState();render.extractRenderState(entity,rs,0,Vec3.ZERO,null);
Set<Integer> colors=new HashSet<>();for(var q:rs.mesh)colors.add(q.color());
var queue=queue(mc);queue.clear();var q=rs.mesh.getLast();var hit=Vec3.atLowerCornerOf(pos).add(SculptureParticles.center(q));
mc.hitResult=new BlockHitResult(hit,Direction.UP,pos,false);mc.level.addBreakingBlockEffects(pos,Direction.UP,false);
check(queue.size()==1&&queue.peek() instanceof SculptureParticles.Fragment,"Native hit emits one voxel chip, no clay debris");
var chip=(SingleQuadParticle)queue.peek();check(color(chip)==q.color(),"Hit chip uses the exact targeted voxel color");
queue.clear();mc.level.addDestroyBlockEffect(pos,state);verify(queue,rs.mesh,colors,pos);int count=queue.size();
queue.clear();mc.level.setBlock(pos,Blocks.AIR.defaultBlockState(),3);check(mc.level.getBlockEntity(pos)==null,"Native block update removes sculpture before break effect");
mc.level.addDestroyBlockEffect(pos,state);verify(queue,rs.mesh,colors,pos);check(queue.size()==count,"Late break event retains voxel colors and offsets");
queue.clear();mc.level.addDestroyBlockEffect(pos,Blocks.STONE.defaultBlockState());check(!queue.isEmpty()&&queue.stream().noneMatch(SculptureParticles.Fragment.class::isInstance),"Other blocks retain native particles");queue.clear();
System.out.println("SCULPTURE125_PARTICLES_PASS native hit/destroy hooks, exact voxel colors, real surface origins, 64-chip bound, late removal and ordinary block regression");
}
private static void verify(Queue<Particle> queue,List<StatuaryRenderer.Quad> mesh,Set<Integer> colors,BlockPos pos){
check(!queue.isEmpty()&&queue.size()<=64,"Bounded destruction particles");var observed=new HashSet<Integer>();
for(var p:queue){check(p instanceof SculptureParticles.Fragment,"No clay fallback particles");int color=color((SingleQuadParticle)p);observed.add(color);check(colors.contains(color),"Particle retains an actual model color");var point=new Vec3(number(p,"x"),number(p,"y"),number(p,"z")).subtract(Vec3.atLowerCornerOf(pos));check(mesh.stream().anyMatch(q->q.color()==color&&SculptureParticles.center(q).equals(point)),"Particle belongs to an actual colored voxel face after rotation/anchor");check(((SingleQuadParticle)p).getQuadSize(0)==1/32f,"One voxel diameter, no oversized clay chips");}
check(observed.size()==colors.size(),"Both fixture material colors occur in destruction chips");
}
@SuppressWarnings("unchecked") private static Queue<Particle> queue(Minecraft mc){try{var f=ParticleEngine.class.getDeclaredField("particlesToAdd");f.setAccessible(true);return (Queue<Particle>)f.get(mc.particleEngine);}catch(Exception e){throw new AssertionError(e);}}
private static double number(Object value,String name){try{Class<?> type=value.getClass();Field f=null;while(type!=null){try{f=type.getDeclaredField(name);break;}catch(NoSuchFieldException ignored){type=type.getSuperclass();}}f.setAccessible(true);return ((Number)f.get(value)).doubleValue();}catch(Exception e){throw new AssertionError(e);}}
private static int color(SingleQuadParticle p){return 0xff000000|Math.round((float)number(p,"rCol")*255)<<16|Math.round((float)number(p,"gCol")*255)<<8|Math.round((float)number(p,"bCol")*255);}
private static void check(boolean ok,String reason){if(!ok)throw new AssertionError(reason);}
}
@@ -0,0 +1,53 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.companion.CompanionService;
import fr.koka.sanctuary.plans.*;
import java.nio.file.*;
import java.util.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.stats.Stats;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.*;
import net.minecraft.world.level.GameType;
final class Sculpture125WorkshopChecks {
static void exercise(ClientGameTestContext c,TestServerContext server){
var pos=new BlockPos(4,1,4);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(3,1,3);p.setGameMode(GameType.SURVIVAL);p.getAbilities().invulnerable=true;p.onUpdateAbilities();p.level().setBlockAndUpdate(pos,Statuary.WORKSHOP.defaultBlockState());
CompanionService.save(p,CompanionService.record(p).note("seen_mobs",""));Statuary.open(p,pos);
});
c.waitForScreen(ClayWorkshopScreen.class);
c.runOnClient(m->{var menu=(ClayWorkshopMenu)m.player.containerMenu;check(!menu.view.statues().contains("minecraft:cow"),"Unseen cow is absent without visiting Metabli");var screen=(ClayWorkshopScreen)m.gui.screen();screen.load(EntityTypes.COW);check(screen.selected()==null&&!screen.busy(),"Unseen animal cannot start local generation");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var menu=(ClayWorkshopMenu)p.containerMenu;menu.getSlot(0).set(new ItemStack(Items.OAK_PLANKS,2));var locked=new VoxelSculpture(StatueDiscoveries.modelId("minecraft:cow"),"Cow",1,1,1,List.of(new VoxelSculpture.Cell(0,0,0,-1)));menu.select(p,locked,Items.OAK_PLANKS,new SculpturePalette(List.of(-1)));check(menu.getSlot(1).getItem().isEmpty(),"Server rejects a locked animal model despite valid material");p.closeContainer();
CompanionService.save(p,CompanionService.record(p).note("seen_mobs","cow"));p.awardStat(Stats.ENTITY_KILLED.get(EntityTypes.SHEEP));p.awardStat(Stats.ENTITY_KILLED_BY.get(EntityTypes.PIG));
});
c.waitFor(m->m.gui.screen()==null,100);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();Statuary.open(p,pos);var menu=(ClayWorkshopMenu)p.containerMenu;check(menu.view.statues().equals(StatueDiscoveries.known(p)),"Workshop uses the exact statue catalogue discoveries");menu.getSlot(0).set(new ItemStack(Items.OAK_PLANKS,4));menu.broadcastChanges();});
c.waitForScreen(ClayWorkshopScreen.class);
c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();var menu=(ClayWorkshopMenu)m.player.containerMenu;check(menu.view.statues().containsAll(List.of("minecraft:cow","minecraft:sheep","minecraft:pig")),"Encounter, kill and killed-by unlocks are transmitted");});
for(var type:List.of(EntityTypes.COW,EntityTypes.SHEEP,EntityTypes.PIG)){
String model=StatueDiscoveries.modelId(BuiltInRegistries.ENTITY_TYPE.getKey(type).toString());
c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(type.getDescription().getString())).findFirst().orElseThrow().onPress(new KeyEvent(257,0,0));});
c.waitTicks(10);c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();System.out.println("SCULPTURE125_WORKSHOP_DEBUG "+type+" busy "+screen.busy()+" source "+(screen.selected()==null?"null":screen.selected().modelId())+" input "+m.player.containerMenu.getSlot(0).getItem()+" output "+m.player.containerMenu.getSlot(1).getItem());});
c.waitFor(m->{var screen=(ClayWorkshopScreen)m.gui.screen();return !screen.busy()&&screen.selected()!=null&&screen.selected().modelId().equals(model)&&m.player.containerMenu.getSlot(1).getItem().is(Statuary.ITEM)&&StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem()).modelId().equals(model);},250);
c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();var source=screen.selected();check(source.cells().size()>20&&Math.max(source.width(),Math.max(source.height(),source.depth()))==16,"Native animal model voxelized at miniature scale");check(ItemStack.matches(screen.preview(),m.player.containerMenu.getSlot(1).getItem()),"Animal preview equals server-paid palette result");});
c.takeScreenshot("sculpture125-workshop-"+BuiltInRegistries.ENTITY_TYPE.getKey(type).getPath());
}
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.PICKUP,m.player));
c.waitFor(m->m.player.containerMenu.getCarried().is(Statuary.ITEM)&&m.player.containerMenu.getSlot(0).getItem().getCount()==3,100);
server.waitFor(s->{var menu=s.getPlayerList().getPlayers().getFirst().containerMenu;return menu.getCarried().is(Statuary.ITEM)&&menu.getSlot(0).getItem().getCount()==3;},100);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(p.containerMenu.getSlot(0).getItem().getCount()==3&&p.containerMenu.getCarried().is(Statuary.ITEM),"Exactly one block consumed for native animal sculpture in survival");});
Path file=PlanClient.directory().resolve("sculpture125-"+UUID.randomUUID()+".glb");
try{Files.createDirectories(file.getParent());Files.write(file,Statuary106ClientChecks.fixture(true));c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(ClayWorkshopScreen.text("imports").getString())).findFirst().orElseThrow().onPress(new KeyEvent(257,0,0));screen.load(file);});
var id=GlbModel.load(file).id();c.waitFor(m->{var screen=(ClayWorkshopScreen)m.gui.screen();return !screen.busy()&&screen.selected()!=null&&screen.selected().modelId().equals(id)&&id.equals(Optional.ofNullable(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())).map(VoxelSculpture::modelId).orElse(""));},250);c.takeScreenshot("sculpture125-workshop-import");
}catch(Exception e){throw new AssertionError(e);}finally{try{Files.deleteIfExists(file);}catch(Exception ignored){}}
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.closeContainer();p.setGameMode(GameType.CREATIVE);p.getAbilities().flying=true;p.onUpdateAbilities();});c.waitFor(m->m.gui.screen()==null,100);
System.out.println("SCULPTURE125_WORKSHOP_PASS encounter/kill/death unlocks, native menu sync, locked server rejection, three textured animals, paid survival craft and GLB import");
}
private static void check(boolean ok,String reason){if(!ok)throw new AssertionError(reason);}
}
@@ -0,0 +1,50 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.plans.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.client.gui.screens.inventory.InventoryScreen;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
/** Actual accessory-slot transfers and recovery from a saved beta.116 head session. */
final class SculptureHat117Checks {
static ItemStack exercise(ClientGameTestContext c,TestServerContext server,VoxelSculpture sculpture){
var original=StatuaryEntity.item(sculpture);original.set(DataComponents.CUSTOM_NAME,Component.literal("Sculpture chapeau 117"));
c.runOnClient(m->m.gui.screen().onClose());
server.waitFor(s->{var p=s.getPlayerList().getPlayers().getFirst();return p.containerMenu==p.inventoryMenu;},100);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();p.getInventory().setItem(0,original.copy());p.inventoryMenu.broadcastFullState();});
c.waitFor(m->ItemStack.matches(m.player.inventoryMenu.getSlot(36).getItem(),original),100);
c.runOnClient(m->m.gui.setScreen(new InventoryScreen(m.player)));
click(c,36);c.waitFor(m->m.player.inventoryMenu.getCarried().is(Statuary.ITEM),100);
click(c,66);c.waitTicks(12);verifyEquipped(c,server,original);
c.takeScreenshot("sculpture117-hat-inventory");
click(c,66);c.waitFor(m->m.player.inventoryMenu.getCarried().is(Statuary.ITEM),100);
click(c,36);
server.waitFor(s->{var p=s.getPlayerList().getPlayers().getFirst();return p.inventoryMenu.getCarried().isEmpty()&&HeadEquipment.get(p).isEmpty()&&ItemStack.matches(p.getInventory().getItem(0),original);},100);
c.waitFor(m->ItemStack.matches(m.player.inventoryMenu.getSlot(36).getItem(),original)&&m.player.inventoryMenu.getSlot(66).getItem().isEmpty(),100);
c.runOnClient(m->check(!new StatuaryRenderer.ItemRenderer().extractArgument(m.player.inventoryMenu.getSlot(36).getItem()).isEmpty(),"Returned sculpture still renders in hotbar"));
click(c,36);click(c,66);c.waitTicks(8);verifyEquipped(c,server,original);
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var saved=HeadBlockService.save(p);check(saved!=null,"Authoritative worn model is saved");
var damaged=original.copy();damaged.remove(DataComponents.BLOCK_ENTITY_DATA);
AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,damaged);HeadEquipment.retainData(p,saved);
HeadBlockService.session(p);p.inventoryMenu.broadcastChanges();
check(ItemStack.matches(HeadEquipment.get(p),original),"Recover already stripped sculpture from its saved worn model");
});
c.waitTicks(8);verifyEquipped(c,server,original);
c.runOnClient(m->m.gui.screen().onClose());
System.out.println("SCULPTURE117_HAT_PASS native equip/remove/re-equip, slot mesh, exact components and old worn-session recovery");
return original;
}
static void verifyEquipped(ClientGameTestContext c,TestServerContext server,ItemStack original){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(ItemStack.matches(HeadEquipment.get(p),original),"Equipped stack retains its geometry and name");check(p.getInventory().getItem(0).isEmpty()&&p.inventoryMenu.getCarried().isEmpty(),"Exactly one sculpture moved into head slot");});
c.waitFor(m->ItemStack.matches(m.player.inventoryMenu.getSlot(66).getItem(),original)&&StatuaryEntity.sculpture(original).equals(StatuaryEntity.sculpture(HeadCosmeticService.appearance(m.player))),150);
c.runOnClient(m->check(!new StatuaryRenderer.ItemRenderer().extractArgument(m.player.inventoryMenu.getSlot(66).getItem()).isEmpty(),"Equipped slot retains visible item geometry"));
}
private static void click(ClientGameTestContext c,int slot){c.runOnClient(m->m.gameMode.handleContainerInput(m.player.inventoryMenu.containerId,slot,0,ContainerInput.PICKUP,m.player));}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,87 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.progression.SanctuaryGameplay;
import fr.koka.sanctuary.weather.SnowAccumulation;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.EntitySpawnReason;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.level.LightLayer;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.gamerules.GameRules;
import net.minecraft.world.phys.shapes.CollisionContext;
public final class Snow108ClientChecks implements FabricClientGameTest {
private static final BlockPos AT = new BlockPos(4,80,4);
public void runTest(ClientGameTestContext c) {
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());
}});
try {
for(boolean enabled:new boolean[]{true,false})try(var world=c.worldBuilder().adjustSettings(s->{
s.setName("Snow108 "+enabled+" "+UUID.randomUUID());s.setSeed("108");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());
s.getGameRules().set(SanctuaryGameplay.ENABLED,enabled,null);
}).create()) {
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&(!enabled||ProgressionClient.active()&&!IntroSpawnFlash.active()),1200);
world.getServer().runOnServer(server->{
var p=server.getPlayerList().getPlayers().getFirst();var l=p.level();p.teleportTo(0,80,-5);
for(int x=-15;x<=15;x++)for(int z=-15;z<=15;z++)l.setBlockAndUpdate(new BlockPos(x,79,z),Blocks.STONE.defaultBlockState());
if(enabled) {
command(l,"sanctuary weather snow");l.setRainLevel(1);
check(l.getGameRules().get(SnowAccumulation.MAX_HEIGHT)==2,"Default two blocks");
for(int i=1;i<=7;i++){l.tickPrecipitation(AT);check(layers(l,AT)==i,"One layer per precipitation event: "+i);}
var pig=EntityTypes.PIG.create(l,EntitySpawnReason.COMMAND);pig.setNoAi(true);pig.setNoGravity(true);pig.setPos(4.5,80.75,4.5);l.addFreshEntity(pig);
l.tickPrecipitation(AT);check(l.getBlockState(AT).is(Blocks.SNOW_BLOCK),"Eighth layer becomes solid snow block");
check(l.getBlockState(AT).getCollisionShape(l,AT,CollisionContext.empty()).bounds().maxY==1,"Full block collision");
check(pig.getY()>=81,"Growth lifts a standing entity");pig.discard();
l.tickPrecipitation(AT);check(layers(l,AT.above())==1,"New layer begins above solid snow");
for(int i=0;i<30;i++)l.tickPrecipitation(AT);
check(l.getBlockState(AT.above()).is(Blocks.SNOW_BLOCK)&&l.getBlockState(AT.above(2)).isAir(),"Default cap at two blocks");
command(l,"gamerule sanctuary:snow_accumulation_height 4");
for(int i=0;i<30;i++)l.tickPrecipitation(AT);
check(l.getBlockState(AT.above(3)).is(Blocks.SNOW_BLOCK)&&l.getBlockState(AT.above(4)).isAir(),"Operator raises cap to four blocks");
command(l,"gamerule sanctuary:snow_accumulation_height -1");
for(int i=0;i<16;i++)l.tickPrecipitation(AT);
check(l.getBlockState(AT.above(5)).is(Blocks.SNOW_BLOCK),"Unlimited mode continues upward");
var fresh=new BlockPos(-5,80,-5);command(l,"gamerule sanctuary:snow_accumulation_height 0");l.tickPrecipitation(fresh);check(l.getBlockState(fresh).isAir(),"Sanctuary zero stops deposits");
command(l,"gamerule sanctuary:snow_accumulation_height 2");l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,0,server);l.tickPrecipitation(fresh);check(l.getBlockState(fresh).isAir(),"Native zero remains authoritative");l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,1,server);
l.setRainLevel(0);l.getWeatherData().setRaining(false);l.tickPrecipitation(fresh);check(l.getBlockState(fresh).isAir(),"No accumulation between showers");
command(l,"sanctuary weather snow_showers");l.setRainLevel(1);l.getWeatherData().setRaining(true);l.tickPrecipitation(fresh);check(layers(l,fresh)==1,"Snow showers accumulate while falling");
var flower=new BlockPos(-6,80,-6);l.setBlockAndUpdate(flower.below(),Blocks.GRASS_BLOCK.defaultBlockState());l.setBlockAndUpdate(flower,Blocks.DANDELION.defaultBlockState());l.tickPrecipitation(flower);check(l.getBlockState(flower).is(Blocks.DANDELION),"Plant preserved");
var water=new BlockPos(-7,79,-7);l.setBlockAndUpdate(water,Blocks.WATER.defaultBlockState());l.tickPrecipitation(water);check(l.getBlockState(water).is(Blocks.WATER)&&l.getBlockState(water.above()).isAir(),"Open water not filled with snow");
var roof=new BlockPos(7,83,-7);l.setBlockAndUpdate(roof,Blocks.STONE.defaultBlockState());l.tickPrecipitation(roof);check(layers(l,roof.above())==1&&l.getBlockState(roof.below()).isAir(),"Snow on roof, not indoors");
var cauldron=new BlockPos(8,80,-8);l.setBlockAndUpdate(cauldron,Blocks.CAULDRON.defaultBlockState());for(int i=0;i<200;i++)l.tickPrecipitation(cauldron);check(l.getBlockState(cauldron).is(Blocks.POWDER_SNOW_CAULDRON),"Native cauldron snow filling preserved");
command(l,"sanctuary weather rain");l.setRainLevel(1);var rainy=new BlockPos(10,80,0);l.tickPrecipitation(rainy);check(l.getBlockState(rainy).isAir(),"Temperate rain is not snow");
command(l,"sanctuary weather vanilla");cold(l);for(int i=0;i<8;i++)l.tickPrecipitation(new BlockPos(0,80,0));check(l.getBlockState(new BlockPos(0,80,0)).is(Blocks.SNOW_BLOCK),"Cold biome also accumulates with vanilla weather");
var light=new BlockPos(11,80,11);l.setBlockAndUpdate(light.below(),Blocks.GLOWSTONE.defaultBlockState());command(l,"sanctuary weather snow");l.setRainLevel(1);
} else {
cold(l);for(int i=0;i<40;i++)l.tickPrecipitation(AT);check(layers(l,AT)==1&&l.getBlockState(AT.above()).isAir(),"Non-Sanctuary world keeps native one-layer limit");
}
});
if(enabled){c.waitTicks(8);world.getServer().runOnServer(s->{var l=s.overworld();var light=new BlockPos(11,80,11);check(l.getBrightness(LightLayer.BLOCK,light)>=10,"Test light propagated");for(int i=0;i<16;i++)l.tickPrecipitation(light);check(l.getBlockState(light).isAir(),"Bright surfaces do not accumulate");});}
}
System.out.println("SNOW108_NATIVE_PASS: layers, solid blocks, vertical stacking, entity lift, caps and commands, weather, light, supports, cauldron and vanilla world");
}finally{intro.set(false);}
}
private static void cold(ServerLevel l){command(l,"fillbiome -8 76 -8 8 88 8 minecraft:snowy_plains");l.getWeatherData().setRaining(true);l.setRainLevel(1);}
private static int layers(ServerLevel l,BlockPos p){var s=l.getBlockState(p);return s.is(Blocks.SNOW)?s.getValue(SnowLayerBlock.LAYERS):0;}
private static void command(ServerLevel l,String s){l.getServer().getCommands().performPrefixedCommand(l.getServer().createCommandSourceStack(),s);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,262 @@
package fr.koka.sanctuary.client;
import com.google.gson.*;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.building.ColoredBricks;
import fr.koka.sanctuary.multiblock.Metatable;
import fr.koka.sanctuary.plans.*;
import java.io.*;
import java.nio.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
/** Actual GLB texture/scene import, paid native crafting, placement, drops, reconnect and plan coexistence. */
public final class Statuary106ClientChecks implements FabricClientGameTest {
private static final Map<Direction,BlockPos> DIRECTIONS=Map.of(Direction.NORTH,new BlockPos(0,1,4),Direction.SOUTH,new BlockPos(4,1,4),Direction.EAST,new BlockPos(-4,1,4),Direction.WEST,new BlockPos(4,1,-2));
private static final BlockPos WORKSHOP=new BlockPos(2,1,2),SCULPTURE=new BlockPos(2,1,0),TABLE=new BlockPos(-3,1,2);
private static final AtomicReference<VoxelSculpture> ORIGINAL=new AtomicReference<>();
private static final AtomicReference<String> MODEL=new AtomicReference<>();
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());button(screen,"sanctuary.progression.enter").onPress(enter());intro.set(false);
}});
Path file=PlanClient.directory().resolve("stat106-cat-"+UUID.randomUUID()+".glb");
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
try{
System.out.println("STATUARY106_LIBRARY "+file);Files.createDirectories(file.getParent());byte[] fixture=fixture();Files.write(file,fixture);
var asset=GlbModel.load(file);var pixels=StatueVoxelizer.miniature(asset.faces());var originalSource=VoxelSculpture.of(asset.id(),asset.name(),pixels);
MODEL.set(originalSource.modelId());check(originalSource.height()==16&&originalSource.width()<16,"Proportions, 16 voxel longest axis");check(originalSource.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>1,"Embedded PNG retains multiple colors");
check(CreationLibrary.files(CreationLibrary.Kind.MODEL).contains(file)&&!CreationLibrary.files(CreationLibrary.Kind.SCHEMATIC).contains(file),"One folder, different filters");
var invalid=fixture.clone();ByteBuffer.wrap(invalid).order(ByteOrder.LITTLE_ENDIAN).putInt(8,1);reject(invalid,"GLB header length");
var oversize=new byte[GlbModel.MAX_BYTES+1];reject(oversize,"File bound");
reject(edit(fixture,j->j.getAsJsonArray("buffers").get(0).getAsJsonObject().addProperty("uri","https://example.invalid/mesh.bin")),"External buffer");
reject(edit(fixture,j->j.getAsJsonArray("images").get(0).getAsJsonObject().addProperty("uri","../../texture.png")),"External texture");
reject(edit(fixture,j->j.getAsJsonArray("accessors").get(0).getAsJsonObject().addProperty("byteOffset",Integer.MAX_VALUE)),"Accessor overflow");
reject(edit(fixture,j->{var a=new JsonArray();a.add(0);j.getAsJsonArray("nodes").get(0).getAsJsonObject().add("children",a);}),"Cyclic scene");
var interop=net.fabricmc.loader.api.FabricLoader.getInstance().getGameDir().resolve("../../../../../build/statuary106-evidence/BoxTextured.glb").normalize();
if(Files.exists(interop)){var box=GlbModel.read(interop);check(box.size()==12,"Independent Khronos indexed GLB");check(!StatueVoxelizer.miniature(box).isEmpty(),"Independent textured box voxelizes");System.out.println("STATUARY106_INTEROP_PASS Khronos BoxTextured GLB");}
check(originalSource.rotate().rotate().rotate().rotate().equals(originalSource),"Four rotations preserve ID and data");
try{new VoxelSculpture(originalSource.modelId(),"bad",16,16,16,List.of(new VoxelSculpture.Cell(0,0,0,-1),new VoxelSculpture.Cell(0,0,0,-1)));throw new AssertionError("Duplicate cell accepted");}catch(IllegalArgumentException expected){}
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Statuary106 "+UUID.randomUUID());s.setSeed("106");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
saved=world.getWorldSave();c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var source=originalSource;
var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.teleportTo(.5,1,.5);p.getInventory().clearContent();p.setGameMode(GameType.SURVIVAL);for(int x=-7;x<=9;x++)for(int z=-5;z<=9;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.level().setBlockAndUpdate(WORKSHOP,Statuary.WORKSHOP.defaultBlockState());
var legacy=net.minecraft.nbt.NbtUtils.writeBlockState(Statuary.BLOCK.defaultBlockState());legacy.remove("properties");
check(net.minecraft.nbt.NbtUtils.readBlockState(net.minecraft.core.registries.BuiltInRegistries.BLOCK,legacy).getValue(Statuary.FACING)==Direction.SOUTH,"Legacy state defaults to unchanged model orientation");
check(net.minecraft.nbt.NbtUtils.readBlockState(net.minecraft.core.registries.BuiltInRegistries.BLOCK,legacy).getValue(Statuary.ANCHOR)==SculptureAnchor.CENTER,"Legacy state keeps centered anchor");
Sculpture113ClientChecks.placementContexts(p);
for(var q:Metatable.members(TABLE))p.level().setBlockAndUpdate(q,Blocks.CRAFTING_TABLE.defaultBlockState());check(Metatable.assemble(p,TABLE),"Metabli");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");});
c.waitFor(m->m.level.getBlockState(WORKSHOP).is(Statuary.WORKSHOP)&&!m.player.isCreative(),200);
c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();Sculpture113ClientChecks.geometry(m,source.modelId());});
open(c);c.runOnClient(m->((ClayWorkshopScreen)m.gui.screen()).onFilesDrop(List.of(file)));
c.waitFor(m->!((ClayWorkshopScreen)m.gui.screen()).busy()&&((ClayWorkshopScreen)m.gui.screen()).selected()!=null,200);c.waitTicks(10);
server.runOnServer(s->{var p=player(s);var menu=(ClayWorkshopMenu)p.containerMenu;check(menu.getSlot(1).getItem().isEmpty(),"No clay, no result");
menu.getSlot(ClayWorkshopMenu.HOTBAR_START).set(new ItemStack(Items.CLAY,3));
p.getInventory().setItem(18,new ItemStack(Items.CLAY,64));p.getInventory().setItem(Inventory.SLOT_OFFHAND,new ItemStack(Items.CLAY,12));
fr.koka.sanctuary.inventory.SanctuaryOverflowAccess.of(p).setItem(17,new ItemStack(Items.CLAY,64));menu.broadcastChanges();});
c.waitFor(m->m.player.containerMenu.getSlot(ClayWorkshopMenu.HOTBAR_START).getItem().getCount()==3,100);
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,ClayWorkshopMenu.HOTBAR_START,0,ContainerInput.QUICK_MOVE,m.player));
c.waitFor(m->m.player.containerMenu.getSlot(1).getItem().is(Statuary.ITEM),100);
c.runOnClient(m->check(m.player.containerMenu.getSlot(1).getItem().getHoverName().getString().equals("Sculpture"),"Crafted sculpture has translated type name EN"));
c.takeScreenshot("statuary106-workshop-en");
checkWorkshopLayout(c);
c.runOnClient(m->{m.options.guiScale().set(3);m.resizeGui();});c.waitTicks(3);checkWorkshopLayout(c);c.takeScreenshot("statuary111-workshop-en-scale3");
// Hidden inventory, overflow and offhand must never join this menu or feed its input.
c.runOnClient(m->check(!((SanctuaryContainerScreenGeometry)(Object)m.gui.screen()).sanctuary$hasIntegratedInventory(),"No expanding player-inventory panel"));
server.runOnServer(s->{var p=player(s);var menu=(ClayWorkshopMenu)p.containerMenu;
check(menu.slots.size()==11&&menu.getSlot(0).getItem().getCount()==3,"Two crafting slots plus exactly nine hotbar slots");
check(!((fr.koka.sanctuary.inventory.SanctuaryExtendedInventoryMenu)(Object)menu).sanctuary$hasExtendedInventory(),"No hidden overflow destination");
menu.clicked(0,Inventory.SLOT_OFFHAND,ContainerInput.SWAP,p);menu.clicked(11,0,ContainerInput.PICKUP,p);menu.clicked(-2,0,ContainerInput.PICKUP,p);
check(menu.quickMoveStack(p,11).isEmpty()&&menu.getCarried().isEmpty(),"Out-of-menu slot rejected");
check(menu.getSlot(0).getItem().getCount()==3&&p.getOffhandItem().getCount()==12,"Offhand swap refused");
check(p.getInventory().getItem(18).getCount()==64&&fr.koka.sanctuary.inventory.SanctuaryOverflowAccess.of(p).getItem(17).getCount()==64,"Reserve clay untouched");
p.getInventory().setItem(18,ItemStack.EMPTY);p.getInventory().setItem(Inventory.SLOT_OFFHAND,ItemStack.EMPTY);fr.koka.sanctuary.inventory.SanctuaryOverflowAccess.of(p).setItem(17,ItemStack.EMPTY);
});
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();});
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;ORIGINAL.set(StatuaryEntity.sculpture(menu.getSlot(1).getItem()));check(ORIGINAL.get().equals(source),"Plain clay applies its sampled palette and preserves model ID");
});
c.runOnClient(m->{for(var family:ColoredBricks.families()){var palette=SculptureMaterialSampler.sample(new ItemStack(family.clay()));var colored=palette.apply(originalSource);check(colored.modelId().equals(source.modelId())&&colored.cells().size()==source.cells().size()&&colored.cells().stream().allMatch(cell->palette.colors().contains(cell.color())),"All 16 clays keep geometry and use actual texture shades");}});
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.PICKUP,m.player));
c.waitFor(m->m.player.containerMenu.getCarried().is(Statuary.ITEM),100);
c.waitTicks(6);
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;check(menu.getSlot(0).getItem().getCount()==2,"Taking one statue costs exactly one clay");check(menu.getCarried().getCount()==1,"One portable item");});
close(c);c.waitTicks(5);
server.runOnServer(s->{var p=player(s);check(count(p,Items.CLAY)==2,"Closing returns unused clay");equip(p,Statuary.ITEM);p.inventoryMenu.broadcastFullState();check(p.getMainHandItem().is(Statuary.ITEM),"Crafted statue equipped on server");});
c.waitFor(m->m.player.getMainHandItem().is(Statuary.ITEM),100);place(c,SCULPTURE);c.waitTicks(6);
c.waitFor(m->m.level.getBlockEntity(SCULPTURE) instanceof StatuaryEntity e&&e.sculpture()!=null,100);
server.runOnServer(s->check(((StatuaryEntity)player(s).level().getBlockEntity(SCULPTURE)).sculpture().equals(source),"Native placement loads voxel data on server"));
c.getInput().lookAt(SCULPTURE);c.waitTicks(5);c.takeScreenshot("statuary106-world-colors");
// Native block loot preserves the entire model. Re-place that same dropped item.
server.runOnServer(s->{var p=player(s);p.level().destroyBlock(SCULPTURE,true);var dropped=p.level().getEntitiesOfClass(ItemEntity.class,new AABB(SCULPTURE).inflate(1)).stream().filter(e->e.getItem().is(Statuary.ITEM)).findFirst().orElseThrow();
check(StatuaryEntity.sculpture(dropped.getItem()).equals(source),"Native drop keeps name, ID and voxels");p.getInventory().setItem(p.getInventory().getSelectedSlot(),dropped.getItem().copy());dropped.discard();p.inventoryMenu.broadcastFullState();});
c.waitFor(m->m.level.getBlockState(SCULPTURE).isAir()&&m.player.getMainHandItem().is(Statuary.ITEM),100);place(c,SCULPTURE);server.waitFor(s->player(s).level().getBlockEntity(SCULPTURE) instanceof StatuaryEntity e&&source.equals(e.sculpture()),100);c.waitFor(m->m.level.getBlockEntity(SCULPTURE) instanceof StatuaryEntity e&&source.equals(e.sculpture()),100);
// Real use-key placements must follow the gaze on both sides of the network.
for(var direction:Direction.Plane.HORIZONTAL){
var pos=DIRECTIONS.get(direction);double px=pos.getX()+.5-direction.getStepX()*2,pz=pos.getZ()+.5-direction.getStepZ()*2;
server.runOnServer(s->{var p=player(s);p.teleportTo(px,1,pz);p.getInventory().setItem(p.getInventory().getSelectedSlot(),StatuaryEntity.item(source));p.inventoryMenu.broadcastFullState();});
c.waitFor(m->m.player.distanceToSqr(px,1,pz)<.05&&m.player.getMainHandItem().is(Statuary.ITEM),150);place(c,pos);
server.waitFor(s->player(s).level().getBlockState(pos).is(Statuary.BLOCK),100);
server.runOnServer(s->{var state=player(s).level().getBlockState(pos);check(state.getValue(Statuary.FACING)==direction&&state.getValue(Statuary.ANCHOR)==SculptureAnchor.CENTER,"Native center placement follows gaze on server");});
c.waitFor(m->m.level.getBlockEntity(pos) instanceof StatuaryEntity e&&source.equals(e.sculpture())&&e.getBlockState().getValue(Statuary.ANCHOR)==SculptureAnchor.CENTER,100);
c.runOnClient(m->Sculpture113ClientChecks.rendered((StatuaryEntity)m.level.getBlockEntity(pos)));
c.takeScreenshot("sculpture119-center-"+direction.getSerializedName());
server.runOnServer(s->{var p=player(s);p.level().removeBlock(pos,false);p.getInventory().setItem(p.getInventory().getSelectedSlot(),StatuaryEntity.item(source));p.inventoryMenu.broadcastFullState();});
c.waitFor(m->m.level.getBlockState(pos).isAir()&&m.player.getMainHandItem().is(Statuary.ITEM),100);place(c,pos,direction.getOpposite());
server.waitFor(s->player(s).level().getBlockState(pos).is(Statuary.BLOCK),100);
server.runOnServer(s->{var state=player(s).level().getBlockState(pos);check(state.getValue(Statuary.FACING)==direction,"Native placement faces gaze: "+direction);
check(state.getValue(Statuary.ANCHOR)==SculptureAnchor.of(direction.getOpposite()),"Aimed edge stored on server");
check(state.rotate(net.minecraft.world.level.block.Rotation.CLOCKWISE_90).getValue(Statuary.FACING)==direction.getClockWise(),"Structure rotation follows native directions");
check(state.mirror(net.minecraft.world.level.block.Mirror.LEFT_RIGHT).getValue(Statuary.FACING)==(direction.getAxis()==Direction.Axis.Z?direction.getOpposite():direction),"Structure mirror follows native directions");
check(((StatuaryEntity)player(s).level().getBlockEntity(pos)).sculpture().equals(source),"Placement never rewrites model voxels");});
c.waitFor(m->m.level.getBlockEntity(pos) instanceof StatuaryEntity e&&source.equals(e.sculpture()),100);
c.runOnClient(m->{var entity=(StatuaryEntity)m.level.getBlockEntity(pos);check(entity.getBlockState().getValue(Statuary.FACING)==direction,"Client receives direction");
check(entity.getBlockState().getValue(Statuary.ANCHOR)==SculptureAnchor.of(direction.getOpposite()),"Client receives aimed edge");
Sculpture113ClientChecks.rendered(entity);});
c.takeScreenshot("statuary111-facing-"+direction.getSerializedName());
}
// Re-place a dropped north-facing model while looking west: no baked or cumulative turn.
var north=DIRECTIONS.get(Direction.NORTH);var west=DIRECTIONS.get(Direction.WEST);
server.runOnServer(s->{var p=player(s);p.level().destroyBlock(north,true);p.level().removeBlock(west,false);
var drop=p.level().getEntitiesOfClass(ItemEntity.class,new AABB(north).inflate(1)).stream().filter(e->e.getItem().is(Statuary.ITEM)).findFirst().orElseThrow();
check(source.equals(StatuaryEntity.sculpture(drop.getItem())),"Direction does not alter dropped item");
p.getInventory().setItem(p.getInventory().getSelectedSlot(),drop.getItem().copy());drop.discard();p.teleportTo(west.getX()+2.5,1,west.getZ()+.5);p.inventoryMenu.broadcastFullState();});
c.waitFor(m->m.player.distanceToSqr(west.getX()+2.5,1,west.getZ()+.5)<.05&&m.player.getMainHandItem().is(Statuary.ITEM)&&m.level.getBlockState(west).isAir(),100);place(c,west,Direction.EAST);
server.waitFor(s->player(s).level().getBlockState(west).is(Statuary.BLOCK),100);
server.runOnServer(s->check(player(s).level().getBlockState(west).getValue(Statuary.FACING)==Direction.WEST,"Dropped statue takes new gaze"));
server.runOnServer(s->player(s).teleportTo(.5,1,.5));c.waitFor(m->m.player.distanceToSqr(.5,1,.5)<.05,100);
// Reuse the file with colored clay; server applies the palette bound to that actual material.
server.runOnServer(s->player(s).getInventory().setItem(player(s).getInventory().getSelectedSlot(),ItemStack.EMPTY));c.waitTicks(4);open(c);
c.runOnClient(m->((ClayWorkshopScreen)m.gui.screen()).load(file));c.waitFor(m->!((ClayWorkshopScreen)m.gui.screen()).busy()&&((ClayWorkshopScreen)m.gui.screen()).selected()!=null,200);c.waitTicks(10);
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;var blue=ColoredBricks.families().stream().filter(f->f.color()==DyeColor.BLUE).findFirst().orElseThrow();menu.getSlot(0).set(new ItemStack(blue.clay(),2));menu.broadcastChanges();});
c.waitFor(m->m.player.containerMenu.getSlot(1).getItem().is(Statuary.ITEM),100);language(c,"fr_fr");c.runOnClient(m->check(m.player.containerMenu.getSlot(1).getItem().getHoverName().getString().equals("Sculpture"),"Crafted sculpture has translated type name FR"));c.takeScreenshot("statuary106-workshop-fr-blue");
checkWorkshopLayout(c);
var expectedBlue=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(ColoredBricks.families().stream().filter(f->f.color()==DyeColor.BLUE).findFirst().orElseThrow().clay())).apply(originalSource));
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;var blue=StatuaryEntity.sculpture(menu.getSlot(1).getItem());check(blue.equals(expectedBlue),"Blue input uses sampled palette and model shading");});
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.QUICK_MOVE,m.player));c.waitTicks(10);
server.runOnServer(s->{var p=player(s);check(p.containerMenu.getSlot(0).getItem().isEmpty(),"Shift crafting consumes stock");check(count(p,Statuary.ITEM)==2,"Shift crafting produces exactly paid quantity");});close(c);
// At the Metabli, the same source creates a full-sized construction plan, with no placement in survival.
server.runOnServer(s->Metatable.open(player(s),TABLE));c.waitForScreen(PlansScreen.class);
c.runOnClient(m->{((PlansScreen)m.gui.screen()).onFilesDrop(List.of(file));button(m.gui.screen(),"sanctuary.plans.palette.concrete").onPress(enter());});c.takeScreenshot("statuary106-metabli-fr");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.generate").onPress(enter()));c.waitFor(m->!PlanClient.busy&&PlanClient.plan!=null&&m.gui.screen()==null,400);
c.runOnClient(m->check(PlanClient.plan.height()==32&&PlanClient.plan.cells().size()>source.cells().size(),"Same source produces real-block statue at selected height"));
server.runOnServer(s->check(((StatuaryEntity)player(s).level().getBlockEntity(SCULPTURE)).sculpture().equals(source),"Plan generation leaves existing miniature intact"));
// Saturated hotbar must not spill crafted statues into hidden inventory or consume unpaid clay.
c.runOnClient(m->PlanClient.clear());open(c);c.runOnClient(m->((ClayWorkshopScreen)m.gui.screen()).load(file));
c.waitFor(m->!((ClayWorkshopScreen)m.gui.screen()).busy()&&((ClayWorkshopScreen)m.gui.screen()).selected()!=null,200);c.waitTicks(10);
var savedHotbar=new AtomicReference<List<ItemStack>>();
server.runOnServer(s->{var p=player(s);var menu=(ClayWorkshopMenu)p.containerMenu;savedHotbar.set(java.util.stream.IntStream.range(0,9).mapToObj(i->p.getInventory().getItem(i).copy()).toList());
for(int i=0;i<9;i++)p.getInventory().setItem(i,new ItemStack(Items.STONE,64));menu.getSlot(0).set(new ItemStack(Items.CLAY,2));menu.broadcastChanges();});
c.waitFor(m->m.player.containerMenu.getSlot(1).getItem().is(Statuary.ITEM)&&m.player.containerMenu.getSlot(2).getItem().is(Items.STONE),100);
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.QUICK_MOVE,m.player));c.waitTicks(6);
server.runOnServer(s->{var p=player(s);var menu=(ClayWorkshopMenu)p.containerMenu;check(menu.getSlot(0).getItem().getCount()==2&&count(p,Statuary.ITEM)==0,"Full hotbar consumes nothing and does not fill reserve");
var nearlyFull=StatuaryEntity.item(source);nearlyFull.setCount(63);p.getInventory().setItem(8,nearlyFull);menu.broadcastChanges();});
c.waitFor(m->m.player.containerMenu.getSlot(10).getItem().getCount()==63,100);
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.QUICK_MOVE,m.player));c.waitTicks(6);
server.runOnServer(s->{var p=player(s);var menu=(ClayWorkshopMenu)p.containerMenu;check(p.getInventory().getItem(8).getCount()==64&&count(p,Statuary.ITEM)==64&&menu.getSlot(0).getItem().getCount()==1,"Partial merge crafts only the one statue that fits");
p.getInventory().setItem(0,ItemStack.EMPTY);menu.broadcastChanges();});
close(c);server.waitFor(s->player(s).containerMenu==player(s).inventoryMenu,100);
server.runOnServer(s->{var p=player(s);check(p.getInventory().getItem(0).is(Items.CLAY)&&p.getInventory().getItem(0).getCount()==1,"Closing returns the unused clay");for(int i=0;i<9;i++)p.getInventory().setItem(i,savedHotbar.get().get(i));p.inventoryMenu.broadcastFullState();});
// Cancel an import by closing the native menu; late worker completion cannot craft in the next session.
open(c);c.runOnClient(m->{((ClayWorkshopScreen)m.gui.screen()).load(file);m.gui.screen().onClose();});c.waitTicks(12);
server.runOnServer(s->check(player(s).containerMenu==player(s).inventoryMenu,"Closed workshop releases crafting session"));
check(Arrays.equals(Files.readAllBytes(file),fixture),"Original GLB never rewritten");
}
Files.delete(file); // Placed models must survive loss of the source file.
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);var server=world.getServer();
server.runOnServer(s->{var entity=(StatuaryEntity)player(s).level().getBlockEntity(SCULPTURE);check(entity.sculpture().equals(ORIGINAL.get()),"Server save and reload preserve source-independent voxels");check(count(player(s),Statuary.ITEM)==2,"Colored inventory statues survive reconnect");});
for(var direction:List.of(Direction.SOUTH,Direction.EAST,Direction.WEST)){
var pos=DIRECTIONS.get(direction);
server.runOnServer(s->check(player(s).level().getBlockState(pos).getValue(Statuary.FACING)==direction&&((StatuaryEntity)player(s).level().getBlockEntity(pos)).sculpture().equals(ORIGINAL.get()),"Direction and model survive server save/reload"));
server.runOnServer(s->check(player(s).level().getBlockState(pos).getValue(Statuary.ANCHOR)==SculptureAnchor.of(direction.getOpposite()),"Anchor survives server save/reload"));
c.waitFor(m->m.level.getBlockEntity(pos) instanceof StatuaryEntity e&&ORIGINAL.get().equals(e.sculpture())&&e.getBlockState().getValue(Statuary.FACING)==direction,150);
}
server.runOnServer(s->check(player(s).level().getBlockState(SCULPTURE).getValue(Statuary.ANCHOR)==SculptureAnchor.CENTER,"Centered placement survives server save/reload"));
c.waitFor(m->m.level.getBlockEntity(SCULPTURE) instanceof StatuaryEntity e&&e.sculpture()!=null,150);c.runOnClient(m->{check(((StatuaryEntity)m.level.getBlockEntity(SCULPTURE)).sculpture().equals(ORIGINAL.get()),"Fresh client receives server model without file");check(m.level.getBlockState(SCULPTURE).getValue(Statuary.ANCHOR)==SculptureAnchor.CENTER,"Fresh client receives centered anchor");});
c.getInput().lookAt(SCULPTURE);c.takeScreenshot("statuary106-reload-no-source");
}
System.out.println("STATUARY106_PASS GLB textured scene, bounded import, shared folder filters, native paid crafting, 16 clay colors, model ID, shift-click, place/drop/re-place, Metabli plan, cancellation, server save/reconnect without source");
System.out.println("STATUARY111_PASS nine-slice GUI FR/EN scales 2/3, four native gaze placements, renderer directions, structure rotation/mirror, legacy state default, drop/re-place and persisted orientation");
System.out.println("STATUARY113_PASS clay sculpture FR/EN, native aimed edges, voxel grid across every footprint/orientation/anchor, side placement, legacy states, rotated/mirrored anchor, save/reconnect");
System.out.println("SCULPTURE119_PASS native centered placements in four directions, center/edge boundaries, odd/even voxel grid, side anchoring and center save/reconnect");
System.out.println("STATUARY112_PASS fixed compact hotbar-only menu, native input transfer, hidden slots/offhand rejected, full hotbar no consumption, partial merge exact cost, closing restitution");
}catch(IOException e){throw new AssertionError(e);}finally{try{Files.deleteIfExists(file);}catch(IOException ignored){}}
}
private static ServerPlayer player(net.minecraft.server.MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static void checkWorkshopLayout(ClientGameTestContext c){c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();var refresh=button(screen,"sanctuary.statuary.refresh");int left=refresh.getX()-10,top=refresh.getY()-127;
check(top>=0&&top+screen.panelHeight()<=screen.height,"Workshop frame fits GUI height");
check(screen.panelHeight()==202&&screen.getMenu().slots.size()==11,"Compact height and nine player slots regardless of reserve capacity");
for(int i=0;i<9;i++){var slot=screen.getMenu().getSlot(i+2);check(slot.container==m.player.getInventory()&&slot.getContainerSlot()==i,"Displayed player slot belongs to active native hotbar");}
for(var slot:screen.getMenu().slots)if(slot.x>=0&&slot.y>=0)check(slot.x>3&&slot.x+17<310&&slot.y>3&&slot.y+17<screen.panelHeight(),"Every visible slot fits inside nine-slice frame");
for(var widget:screen.children())if(widget instanceof Button b)check(b.getX()>=left&&b.getRight()<=left+310&&b.getY()>=top&&b.getBottom()<=top+145,"Every action stays clear of explanations and inventory");
});}
private static void close(ClientGameTestContext c){c.runOnClient(m->m.gui.screen().onClose());c.waitFor(m->m.gui.screen()==null,100);}
private static void open(ClientGameTestContext c){c.runOnClient(m->m.gameMode.useItemOn(m.player,InteractionHand.MAIN_HAND,new BlockHitResult(Vec3.atCenterOf(WORKSHOP),Direction.UP,WORKSHOP,false)));c.waitForScreen(ClayWorkshopScreen.class);}
private static void place(ClientGameTestContext c,BlockPos pos){place(c,pos,null);}
private static void place(ClientGameTestContext c,BlockPos pos,Direction edge){
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(pos.getX()+.5+(edge==null?0:edge.getStepX()*.4),pos.getY()-.001,pos.getZ()+.5+(edge==null?0:edge.getStepZ()*.4))));c.waitTicks(3);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult hit&&hit.getBlockPos().equals(pos.below())&&hit.getDirection()==Direction.UP,"Native cursor targets support top face"));
c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);
}
private static int count(ServerPlayer p,Item item){int n=0;for(int i=0;i<p.getInventory().getContainerSize();i++)if(p.getInventory().getItem(i).is(item))n+=p.getInventory().getItem(i).getCount();return n;}
private static void equip(ServerPlayer p,Item item){var inv=p.getInventory();for(int i=0;i<inv.getContainerSize();i++)if(inv.getItem(i).is(item)){var old=inv.getSelectedItem();var found=inv.getItem(i);inv.setItem(i,old);inv.setItem(inv.getSelectedSlot(),found);return;}throw new AssertionError("Missing crafted item");}
private static void language(ClientGameTestContext c,String lang){var reload=c.computeOnClient(m->{m.getLanguageManager().setSelected(lang);return m.reloadResourcePacks();});c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static java.util.stream.Stream<GuiEventListener> widgets(GuiEventListener root){return java.util.stream.Stream.concat(java.util.stream.Stream.of(root),root instanceof ContainerEventHandler container?container.children().stream().flatMap(Statuary106ClientChecks::widgets):java.util.stream.Stream.empty());}
private static Button button(GuiEventListener root,String key){return widgets(root).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow(()->new AssertionError("Missing button "+key));}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
private static void reject(byte[] bytes,String label)throws IOException{try{GlbModel.read(bytes);throw new AssertionError("Accepted "+label);}catch(IOException expected){}}
private static byte[] edit(byte[] file,java.util.function.Consumer<JsonObject> edit){
var input=ByteBuffer.wrap(file).order(ByteOrder.LITTLE_ENDIAN);int length=input.getInt(12);byte[] original=Arrays.copyOfRange(file,20,20+length);var json=JsonParser.parseString(new String(original,java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject();edit.accept(json);
byte[] text=json.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);int padded=(text.length+3)/4*4;byte[] binary=Arrays.copyOfRange(file,20+length,file.length);var out=ByteBuffer.allocate(20+padded+binary.length).order(ByteOrder.LITTLE_ENDIAN);out.putInt(0x46546C67).putInt(2).putInt(out.capacity()).putInt(padded).putInt(0x4E4F534A).put(text);while(out.position()<20+padded)out.put((byte)' ');out.put(binary);return out.array();
}
/** A tiny authored cat built from triangle boxes, embedded 2×2 texture and a translated scene node. */
static byte[] fixture()throws IOException{
return fixture(false);
}
static byte[] fixture(boolean uniform)throws IOException{
var xyz=new ArrayList<Float>();var uv=new ArrayList<Float>();
for(float[] box:List.of(new float[]{.2f,0,.1f,.8f,1,.65f},new float[]{.1f,1,.05f,.9f,1.7f,.7f},new float[]{.1f,1.7f,.1f,.3f,2.1f,.5f},new float[]{.7f,1.7f,.1f,.9f,2.1f,.5f},new float[]{.75f,.1f,.2f,1.1f,.65f,.5f})){
float x=box[0],y=box[1],z=box[2],X=box[3],Y=box[4],Z=box[5];float[][] v={{x,y,z},{X,y,z},{X,Y,z},{x,Y,z},{x,y,Z},{X,y,Z},{X,Y,Z},{x,Y,Z}};
int[][] quads={{0,3,2,1},{5,6,7,4},{4,7,3,0},{1,2,6,5},{3,7,6,2},{4,0,1,5}};
for(var q:quads)for(int i:new int[]{0,1,2,0,2,3}){for(float f:v[q[i]])xyz.add(f);uv.add(i==0||i==1?0f:1f);uv.add(i==0||i==3?1f:0f);}
}
var image=new java.awt.image.BufferedImage(2,2,java.awt.image.BufferedImage.TYPE_INT_ARGB);image.setRGB(0,0,0xFFFF9933);image.setRGB(1,0,0xFF44CCAA);image.setRGB(0,1,0xFF4477EE);image.setRGB(1,1,0xFFFFFFEE);
if(uniform)for(int y=0;y<2;y++)for(int x=0;x<2;x++)image.setRGB(x,y,0xFF808080);
var png=new ByteArrayOutputStream();javax.imageio.ImageIO.write(image,"png",png);
int pbytes=xyz.size()*4,ubytes=uv.size()*4,ibytes=png.size(),total=pbytes+ubytes+ibytes;var binary=ByteBuffer.allocate((total+3)/4*4).order(ByteOrder.LITTLE_ENDIAN);for(float f:xyz)binary.putFloat(f);for(float f:uv)binary.putFloat(f);binary.put(png.toByteArray());
String json="""
{"asset":{"version":"2.0"},"scene":0,"scenes":[{"nodes":[0]}],"nodes":[{"mesh":0,"translation":[3,2,-1]}],
"meshes":[{"primitives":[{"attributes":{"POSITION":0,"TEXCOORD_0":1},"material":0}]}],
"materials":[{"pbrMetallicRoughness":{"baseColorTexture":{"index":0}}}],"textures":[{"source":0}],"images":[{"mimeType":"image/png","bufferView":2}],
"buffers":[{"byteLength":%d}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":%d},{"buffer":0,"byteOffset":%d,"byteLength":%d},{"buffer":0,"byteOffset":%d,"byteLength":%d}],
"accessors":[{"bufferView":0,"componentType":5126,"count":%d,"type":"VEC3"},{"bufferView":1,"componentType":5126,"count":%d,"type":"VEC2"}]}
""".formatted(total,pbytes,pbytes,ubytes,pbytes+ubytes,ibytes,xyz.size()/3,uv.size()/2);
byte[] text=json.getBytes(java.nio.charset.StandardCharsets.UTF_8);int jl=(text.length+3)/4*4;var out=ByteBuffer.allocate(28+jl+binary.capacity()).order(ByteOrder.LITTLE_ENDIAN);out.putInt(0x46546C67).putInt(2).putInt(out.capacity()).putInt(jl).putInt(0x4E4F534A).put(text);while(out.position()<20+jl)out.put((byte)' ');out.putInt(binary.capacity()).putInt(0x004E4942).put(binary.array());return out.array();
}
}
@@ -0,0 +1,111 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.expansion.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.core.*;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.levelgen.structure.*;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
/** Real failing seed, native loot and traps, and cache/chunk save reload. */
public final class Temple097ClientChecks implements FabricClientGameTest {
private static final AtomicReference<BlockPos> HALL=new AtomicReference<>();
public void runTest(ClientGameTestContext c) {
System.setProperty("sanctuary.expansion.structureDiagnostics","true");
var lastHello=new AtomicReference<HelloWorldScreen>();
net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents.END_CLIENT_TICK.register(m->{
if(m.gui.screen() instanceof HelloWorldScreen screen&&lastHello.getAndSet(screen)!=screen){
IntroClient.setQuickTestIntro(true);fr.koka.sanctuary.gametest.Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(net.minecraft.client.gui.components.Button.class::isInstance).map(net.minecraft.client.gui.components.Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(new net.minecraft.client.input.KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0));
screen.children().stream().filter(net.minecraft.client.gui.components.Button.class::isInstance).map(net.minecraft.client.gui.components.Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(new net.minecraft.client.input.KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0));
}
});
c.runOnClient(m->{m.options.renderDistance().set(4);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
var baseline=new AtomicReference<List<String>>();TestWorldSave save;
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Temple097 "+UUID.randomUUID());s.setSeed("-4700804240597771092");s.setAllowCommands(true);s.setGenerateStructures(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());}).create()) {
save=world.getWorldSave();
world.getServer().runOnServer(s->baseline.set(inspect(s.overworld())));
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var at=HALL.get();p.setGameMode(net.minecraft.world.level.GameType.CREATIVE);p.setNoGravity(true);p.getAbilities().flying=true;p.onUpdateAbilities();p.teleportTo(at.getX()+1.5,at.getY()+3,at.getZ()-5);});
c.waitFor(m->m.player.distanceToSqr(HALL.get().getX()+1.5,HALL.get().getY()+3,HALL.get().getZ()-5)<2,200);
c.getInput().lookAt(HALL.get().offset(1,0,1));c.waitTicks(25);c.takeScreenshot("catalog099-hall-metatable");
}
try(var world=save.open()) {
world.getServer().runOnServer(s->{check(baseline.get().equals(inspect(s.overworld())),"Native structures and loot survive reload");var g=(SanctuaryChunkGenerator)s.overworld().getChunkSource().getGenerator();check(g.planCache().hits()>0,"Reopen restores native planning cache");});
}
System.out.println("TEMPLE097_PASS: supplied seed, two complete native temples, native starts, loot and traps, metadata round-trip, locate, chunk/cache reload");
}
private static void hall099(ServerLevel level,SanctuaryChunkGenerator host){
var hall=host.undergroundHall22().networks().stream().flatMap(n->n.modules().stream()).filter(m->m.kind()==fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Kind.EXPANSION_HALL).findFirst().orElseThrow();
var origin=new BlockPos(hall.minX()+hall.width()/2-1,hall.minY()+3,hall.minZ()+hall.depth()/2-1);
for(var pos:fr.koka.sanctuary.multiblock.Metatable.members(origin))level.getChunkAt(pos);
HALL.set(origin);
System.out.println("HALL099 states="+fr.koka.sanctuary.multiblock.Metatable.members(origin).stream().map(level::getBlockState).toList());
check(fr.koka.sanctuary.multiblock.Metatable.state(level,origin,true)==1,"Generated hall contains an assembled central Metatable");
for(var pos:fr.koka.sanctuary.multiblock.Metatable.members(origin))check(level.getBlockState(pos.below()).is(Blocks.SMOOTH_STONE),"Metatable rests on existing raised plinth");
var codec=StructurePieceSerializationContext.fromLevel(level);
var modern=new fr.koka.sanctuary.worldgen.city.LostCityUndergroundPiece20(hall,true);var tag=modern.createTag(codec);
check(tag.getBooleanOr("SanctuaryMetatable099",false),"New hall piece persists opt-in");
var restored=new fr.koka.sanctuary.worldgen.city.LostCityUndergroundPiece20(codec,tag);
check(restored.createTag(codec).equals(tag),"New hall piece round-trips");
tag.remove("SanctuaryMetatable099");var historical=new fr.koka.sanctuary.worldgen.city.LostCityUndergroundPiece20(codec,tag);
check(!historical.createTag(codec).contains("SanctuaryMetatable099"),"Historical unmarked pieces never opt into new furniture");
System.out.println("HALL099_PASS: assembled Metatable on central plinth at "+origin+", native saved opt-in, historical absence preserved");
}
private static List<String> inspect(ServerLevel level) {
var host=(SanctuaryChunkGenerator)level.getChunkSource().getGenerator();
check(host.expeditionTemples002().size()==2,"Both expedition temples required");var evidence=new ArrayList<String>();
hall099(level,host);
for(var temple:host.expeditionTemples002()) {
var planned=temple.start();var cp=planned.getChunkPos();var box=ExpeditionTemples002.footprint(planned);
for(int x=box.minX()>>4;x<=box.maxX()>>4;x++)for(int z=box.minZ()>>4;z<=box.maxZ()>>4;z++)level.getChunk(x,z);
var actual=level.structureManager().getStartForStructure(temple.structure().value(),level.getChunk(cp.x(),cp.z()));
check(actual!=null&&actual.isValid(),"Native start exists");
var codec=StructurePieceSerializationContext.fromLevel(level);var nbt=actual.createTag(codec,cp);var restored=StructureStart.loadStaticStart(codec,nbt,level.getSeed());
check(restored!=null&&restored.createTag(codec,cp).equals(nbt),"NBT round trip includes optional foundation metadata");
int chests=0,tnt=0,dispensers=0,masonry=0;
for(var at:BlockPos.betweenClosed(box.minX(),box.minY(),box.minZ(),box.maxX(),box.maxY(),box.maxZ())) {
var block=level.getBlockState(at);if(block.is(Blocks.CHEST))chests++;if(block.is(Blocks.TNT))tnt++;if(block.is(Blocks.DISPENSER))dispensers++;
if(block.is(Blocks.SANDSTONE)||block.is(Blocks.COBBLESTONE)||block.is(Blocks.MOSSY_COBBLESTONE))masonry++;
}
foundationChecks(level,actual);
var id=temple.structure().unwrapKey().orElseThrow().identifier().toString();boolean desert=id.endsWith("desert_pyramid");
check(chests>=(desert?4:2)&&masonry>100,"Temple masonry and chests: "+id+" "+chests+" "+masonry);
check(desert?tnt==9:dispensers==2,"Native traps: "+id+" TNT="+tnt+" dispensers="+dispensers);
check(host.findNearestMapStructure(level,HolderSet.direct(temple.structure()),actual.getBoundingBox().getCenter(),1,false)!=null,"Locate finds temple");
String row=id+" "+cp+" "+box+" chests="+chests+" TNT="+tnt+" dispensers="+dispensers+" masonry="+masonry;
evidence.add(row);System.out.println("TEMPLE097 "+row+" metadata="+((NativeStructureMetadata23)(Object)actual).sanctuary$native23());
}
return List.copyOf(evidence);
}
static void foundationChecks(ServerLevel level,StructureStart original) {
var codec=StructurePieceSerializationContext.fromLevel(level);var at=original.getChunkPos();
var copy=StructureStart.loadStaticStart(codec,original.createTag(codec,at),level.getSeed());
var old=((NativeStructureMetadata23)(Object)copy).sanctuary$native23();var box=copy.getPieces().getFirst().getBoundingBox();int bottom=box.minY()-16;
((NativeStructureMetadata23)(Object)copy).sanctuary$native23(new NativeStructureMetadata23.Data(old.islandSeed(),old.seaLevel(),bottom));
var encoded=copy.createTag(codec,at);var restored=StructureStart.loadStaticStart(codec,encoded,level.getSeed());
check(((NativeStructureMetadata23)(Object)restored).sanctuary$native23().templeFoundationMinY()==bottom,"Prepared foundation survives native NBT");
var writes=new HashMap<BlockPos,net.minecraft.world.level.block.state.BlockState>();var clipping=new AtomicReference<BoundingBox>();
var region=(net.minecraft.world.level.WorldGenLevel)java.lang.reflect.Proxy.newProxyInstance(getClassLoader(),new Class[]{net.minecraft.world.level.WorldGenLevel.class},(proxy,method,args)->switch(method.getName()) {
case "getMinY" -> level.getMinY();case "getMaxY" -> level.getMaxY();
case "setBlock" -> {var pos=((BlockPos)args[0]).immutable();check(clipping.get().isInside(pos),"Foundation never writes outside current decorating chunk");writes.put(pos,(net.minecraft.world.level.block.state.BlockState)args[1]);yield true;}
default -> throw new AssertionError("Unexpected world access: "+method.getName());
});
for(int x=box.minX()>>4;x<=box.maxX()>>4;x++)for(int z=box.minZ()>>4;z<=box.maxZ()>>4;z++) {
var bounds=new BoundingBox(x*16,level.getMinY(),z*16,x*16+15,level.getMaxY()-1,z*16+15);clipping.set(bounds);ExpeditionTemples002.prepareFoundation(region,restored,bounds);
}
check(writes.size()==box.getXSpan()*box.getZSpan()*16,"Continuous foundation under the entire native footprint");
check(writes.keySet().stream().allMatch(p->p.getY()>=bottom&&p.getY()<box.minY()),"Foundation leaves building and underground native room construction to vanilla");
writes.clear();clipping.set(ExpeditionTemples002.footprint(original));ExpeditionTemples002.prepareFoundation(region,original,clipping.get());
check(writes.isEmpty(),"Existing unmarked temples never receive terrain edits");
}
private static ClassLoader getClassLoader(){return Temple097ClientChecks.class.getClassLoader();}
private static void check(boolean condition,String message){if(!condition)throw new AssertionError(message);}
}
@@ -0,0 +1,73 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.building.ColoredBricks;
import java.util.*;
import net.minecraft.core.*;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.tags.*;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.*;
import net.minecraft.world.item.context.UseOnContext;
import net.minecraft.world.item.crafting.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.properties.WallSide;
import net.minecraft.world.phys.*;
/** Gameplay regressions using native placement, neighbor updates and recipe matching. */
final class Walls118Checks {
static void server(MinecraftServer server){
var player=server.getPlayerList().getPlayers().getFirst();var level=player.level();
for(int x=-1;x<=17;x++)for(int z=9;z<=13;z++)level.setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
int index=0;
for(var family:ColoredBricks.families()){
var wall=family.wall();var state=wall.defaultBlockState();
check(wall instanceof WallBlock&&state.is(BlockTags.WALLS)&&state.is(BlockTags.MINEABLE_WITH_PICKAXE),"Native wall type, connection and mining tags");
check(new ItemStack(wall).is(ItemTags.WALLS),"Native wall item tag");
var input=CraftingInput.of(3,2,Collections.nCopies(6,new ItemStack(family.bricks())));
var recipe=server.getRecipeManager().getRecipeFor(RecipeType.CRAFTING,input,level).orElseThrow().value();var result=recipe.assemble(input);
check(result.is(wall.asItem())&&result.getCount()==6,"Six matching bricks craft six walls");
var drops=Block.getDrops(state,level,new BlockPos(0,2,0),null,player,new ItemStack(Items.DIAMOND_PICKAXE));
check(drops.size()==1&&drops.getFirst().is(wall.asItem())&&drops.getFirst().getCount()==1,"Wall drops its own color");
for(int z:new int[]{10,12}){
var pos=new BlockPos(index,1,z);
player.teleportTo(index+.5,1,z+2.5);
if(z==12)level.setBlockAndUpdate(pos,Blocks.WATER.defaultBlockState());
player.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(wall));
var hit=new BlockHitResult(Vec3.atBottomCenterOf(pos),Direction.UP,pos.below(),false);
var placed=player.getMainHandItem().useOn(new UseOnContext(player,InteractionHand.MAIN_HAND,hit));
check(placed.consumesAction(),"Native wall item placement: "+family.color()+" at "+pos+" = "+placed+", existing "+level.getBlockState(pos));
check(level.getBlockState(pos).is(wall),"Placed wall keeps its color");
check(level.getBlockState(pos).getValue(WallBlock.WATERLOGGED)==(z==12),"Native waterlogging on placement");
}
index++;
}
for(int i=1;i<15;i++){
var pos=new BlockPos(i,1,10);var state=level.getBlockState(pos);
check(state.getValue(WallBlock.EAST)==WallSide.LOW&&state.getValue(WallBlock.WEST)==WallSide.LOW&&!state.getValue(WallBlock.UP),"Mixed-color straight wall removes middle post");
level.setBlockAndUpdate(pos.above(),Blocks.STONE.defaultBlockState());
state=level.getBlockState(pos);check(state.getValue(WallBlock.EAST)==WallSide.TALL&&state.getValue(WallBlock.WEST)==WallSide.TALL,"Full block above raises connected sides");
var vanilla=Blocks.BRICK_WALL.withPropertiesOf(state);
check(state.getCollisionShape(level,pos).toAabbs().equals(vanilla.getCollisionShape(level,pos).toAabbs()),"Collision equals native brick wall");
level.removeBlock(pos.above(),false);
}
var end=new BlockPos(16,1,10);level.setBlockAndUpdate(end,Blocks.BRICK_WALL.defaultBlockState());
check(level.getBlockState(end.west()).getValue(WallBlock.EAST)==WallSide.LOW,"Connects to vanilla brick wall");
saved(level);
}
static void creative(List<ItemStack> display){
var colors=List.of(DyeColor.WHITE,DyeColor.LIGHT_GRAY,DyeColor.GRAY,DyeColor.BLACK,DyeColor.BROWN,DyeColor.RED,DyeColor.ORANGE,DyeColor.YELLOW,DyeColor.LIME,DyeColor.GREEN,DyeColor.CYAN,DyeColor.LIGHT_BLUE,DyeColor.BLUE,DyeColor.PURPLE,DyeColor.MAGENTA,DyeColor.PINK);
var families=ColoredBricks.families().stream().sorted(Comparator.comparingInt(f->colors.indexOf(f.color()))).toList();
int start=-1;for(int i=0;i<display.size();i++)if(display.get(i).is(families.getFirst().wall().asItem())){start=i;break;}
check(start>0,"Wall series is visible in Colored Blocks");
for(int i=0;i<16;i++)check(display.get(start+i).is(families.get(i).wall().asItem()),"Contiguous series of sixteen in native color order");
check(display.get(start-1).is(families.getLast().slab().asItem())&&display.get(start+16).is(families.getFirst().clay().asItem()),"Wall series follows slabs and precedes clay");
}
static void saved(ServerLevel level){
int i=0;for(var family:ColoredBricks.families()){
check(level.getBlockState(new BlockPos(i,1,10)).is(family.wall()),"Persisted wall color");
var wet=level.getBlockState(new BlockPos(i++,1,12));check(wet.is(family.wall())&&wet.getValue(WallBlock.WATERLOGGED)&&wet.getFluidState().isSource(),"Persisted waterlogged wall");
}
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,140 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.renderer.v1.Renderer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.RandomSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.level.block.piston.PistonBaseBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.*;
import net.minecraft.world.phys.*;
/** Real client gestures and persisted server state; rendered UVs must change without moving geometry. */
public final class Wrench120ClientChecks implements FabricClientGameTest {
private static final BlockPos TARGET=new BlockPos(0,1,0),CHEST=new BlockPos(2,1,0),DIRT=new BlockPos(-2,1,0);
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Wrench120 "+UUID.randomUUID());s.setSeed("120");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=player(s);var l=p.level();p.teleportTo(.5,1,3.2);p.getInventory().setItem(p.getInventory().getSelectedSlot(),new ItemStack(Multiblocks.STEVE_WRENCH));p.inventoryMenu.broadcastFullState();
l.setBlockAndUpdate(TARGET,Blocks.OAK_STAIRS.defaultBlockState());l.setBlockAndUpdate(CHEST,Blocks.CHEST.defaultBlockState());((ChestBlockEntity)l.getBlockEntity(CHEST)).setItem(0,new ItemStack(Items.DIAMOND,23));l.setBlockAndUpdate(DIRT,Blocks.DIRT.defaultBlockState());
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
int tested=0;for(var block:BuiltInRegistries.BLOCK)for(var property:block.getStateDefinition().getProperties()){
var before=block.defaultBlockState();var after=GoldenWrench.cycle(before,property,false);
check(after.is(block)&&GoldenWrench.cycle(after,property,true)==before,"Every registered native property has reversible values");tested++;
}
check(tested>1000,"Broad block property coverage");System.out.println("WRENCH120_PROPERTIES "+tested);
});
c.waitFor(m->m.player.getMainHandItem().is(Multiblocks.STEVE_WRENCH)&&m.player.distanceToSqr(.5,1,3.2)<.1&&m.level.getBlockState(TARGET).is(Blocks.OAK_STAIRS),150);
var stairs=c.computeOnClient(m->m.level.getBlockState(TARGET));click(c,TARGET,false,false);
server.waitFor(s->player(s).level().getBlockState(TARGET)!=stairs,100);c.waitFor(m->m.level.getBlockState(TARGET)!=stairs,100);
server.runOnServer(s->{var actual=player(s).level().getBlockState(TARGET);check(actual.getValue(StairBlock.FACING)!=stairs.getValue(StairBlock.FACING)&&actual.getValue(StairBlock.HALF)==stairs.getValue(StairBlock.HALF),"Right click changes facing only");});
click(c,TARGET,false,true);server.waitFor(s->player(s).level().getBlockState(TARGET)==stairs,100);
click(c,TARGET,true,false);server.runOnServer(s->check(GoldenWrench.selected(player(s),stairs).equals("half"),"Left click selects half without breaking creative block: "+GoldenWrench.selected(player(s),stairs)));
click(c,TARGET,false,false);server.waitFor(s->player(s).level().getBlockState(TARGET).getValue(StairBlock.HALF)==Half.TOP,100);
click(c,TARGET,true,false);server.runOnServer(s->check(GoldenWrench.selected(player(s),stairs).equals("shape"),"Next property is stair shape"));
click(c,TARGET,false,false);server.waitFor(s->player(s).level().getBlockState(TARGET).getValue(StairBlock.SHAPE)!=StairsShape.STRAIGHT,100);
c.takeScreenshot("wrench120-stairs");
// A container rotates in place; inventory and block entity survive the full native interaction.
click(c,CHEST,false,false);server.runOnServer(s->{var p=player(s);var chest=(ChestBlockEntity)p.level().getBlockEntity(CHEST);check(chest.getItem(0).is(Items.DIAMOND)&&chest.getItem(0).getCount()==23,"Chest contents retained");check(p.containerMenu==p.inventoryMenu,"Wrench does not open ordinary chest");check(chest.getBlockState().getValue(ChestBlock.FACING)!=Blocks.CHEST.defaultBlockState().getValue(ChestBlock.FACING),"Chest rotates");});
// Ordinary survival without commands retains Steves full powers; dirt keeps its native ID, shape and loot.
server.runOnServer(s->{s.getWorldData().setAllowCommands(false);player(s).setGameMode(GameType.SURVIVAL);});c.waitFor(m->!m.player.isCreative(),100);
var original=c.computeOnClient(m->geometry(m,DIRT));c.takeScreenshot("wrench120-dirt-before");
click(c,DIRT,false,false);server.waitFor(s->WrenchTextures.rotation(player(s).level().getChunkAt(DIRT),DIRT)==1,100);c.waitFor(m->WrenchTextureModels.rotation(DIRT)==1,100);
var rotated=c.computeOnClient(m->geometry(m,DIRT));checkUvs(original,rotated);c.takeScreenshot("wrench120-dirt-90");
server.runOnServer(s->check(player(s).level().getBlockState(DIRT)==Blocks.DIRT.defaultBlockState(),"Dirt type and native state unchanged"));
click(c,DIRT,true,false);server.runOnServer(s->check(player(s).level().getBlockState(DIRT).is(Blocks.DIRT),"Left click never breaks dirt in survival"));
click(c,DIRT,false,true);c.waitFor(m->WrenchTextureModels.rotation(DIRT)==0,100);checkGeometry(original,c.computeOnClient(m->geometry(m,DIRT)),"Reverse restores exact UVs");
for(int i=1;i<=4;i++){click(c,DIRT,false,false);int expected=i%4;c.waitFor(m->WrenchTextureModels.rotation(DIRT)==expected,100);}
checkGeometry(original,c.computeOnClient(m->geometry(m,DIRT)),"Four turns restore exact original geometry and UVs");
click(c,DIRT,false,false);c.waitFor(m->WrenchTextureModels.rotation(DIRT)==1,100);
var reload=c.computeOnClient(Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();checkGeometry(rotated,c.computeOnClient(m->geometry(m,DIRT)),"Resource reload retains texture rotation");
// Replacing a block clears the visual receipt; blockstate edits retain it.
server.runOnServer(s->{var l=player(s).level();var q=new BlockPos(-3,1,0);l.setBlockAndUpdate(q,Blocks.OAK_LOG.defaultBlockState());WrenchTextures.turn(l,q,1);l.setBlock(q,Blocks.OAK_LOG.defaultBlockState().setValue(RotatedPillarBlock.AXIS,Direction.Axis.X),18);check(WrenchTextures.rotation(l.getChunkAt(q),q)==1,"State edit preserves texture rotation");l.removeBlock(q,false);l.setBlockAndUpdate(q,Blocks.OAK_LOG.defaultBlockState());check(WrenchTextures.rotation(l.getChunkAt(q),q)==0,"Break/replacement clears rotation");
var p=player(s);p.setGameMode(GameType.ADVENTURE);check(!GoldenWrench.allowed(p,TARGET,InteractionHand.MAIN_HAND,Direction.UP),"Adventure cannot edit");p.setGameMode(GameType.SPECTATOR);check(!GoldenWrench.allowed(p,TARGET,InteractionHand.MAIN_HAND,Direction.UP),"Spectator cannot edit");p.setGameMode(GameType.SURVIVAL);check(!GoldenWrench.allowed(p,TARGET.offset(100,0,0),InteractionHand.MAIN_HAND,Direction.UP),"Out-of-range edit refused");
});
// A full Metabli still assembles with two normal right clicks, then separates with sneak-use.
var table=new BlockPos(6,1,0);server.runOnServer(s->{var p=player(s);p.getInventory().setItem(p.getInventory().getSelectedSlot(),new ItemStack(Multiblocks.WRENCH));p.inventoryMenu.broadcastFullState();for(var q:Metatable.members(table))p.level().setBlockAndUpdate(q,Blocks.CRAFTING_TABLE.defaultBlockState());p.teleportTo(7.5,1,4);});
c.waitFor(m->m.player.distanceToSqr(7.5,1,4)<.1&&m.level.getBlockState(table).is(Blocks.CRAFTING_TABLE),100);
click(c,table.offset(1,0,2),false,false);click(c,table.offset(1,0,2),false,false);
server.waitFor(s->player(s).level().getBlockState(table).is(Metatable.BLOCK),100);c.waitFor(m->m.level.getBlockState(table).is(Metatable.BLOCK),100);
click(c,table.offset(1,0,2),true,false);server.runOnServer(s->check(Metatable.state(player(s).level(),table,true)==1,"Left click cannot corrupt a multiblock's part states"));
click(c,table.offset(1,0,2),false,true);server.waitFor(s->Metatable.state(player(s).level(),table,false)==1,100);
// Both container assemblers and the piston assembler must win over generic state editing.
for(var component:List.of(Blocks.BARREL,Blocks.FURNACE)){
server.runOnServer(s->{var l=player(s).level();for(int y=0;y<3;y++)for(int z=0;z<3;z++)for(int x=0;x<3;x++)l.setBlockAndUpdate(table.offset(x,y,z),component.defaultBlockState());});
c.waitFor(m->m.level.getBlockState(table.offset(1,0,2)).is(component),100);
click(c,table.offset(1,0,2),false,false);click(c,table.offset(1,0,2),false,false);
server.runOnServer(s->check(MultiblockData.get(player(s).level()).at(table)!=null,"Complete container machine assembles before generic rotation"));
click(c,table.offset(1,0,2),false,true);
server.runOnServer(s->check(MultiblockData.get(player(s).level()).at(table)==null,"Container machine still separates"));
}
server.runOnServer(s->{var l=player(s).level();for(int y=2;y>=0;y--)for(int z=0;z<3;z++)for(int x=0;x<3;x++)l.setBlockAndUpdate(table.offset(x,y,z),y==0?Blocks.PISTON.defaultBlockState().setValue(PistonBaseBlock.FACING,Direction.UP):Blocks.AIR.defaultBlockState());});
c.waitFor(m->m.level.getBlockState(table.offset(1,0,2)).is(Blocks.PISTON),100);
click(c,table.offset(1,0,2),false,false);click(c,table.offset(1,0,2),false,false);
server.waitFor(s->SuperPistons.status(player(s).level(),table.offset(1,0,1),Direction.UP,false)==1,100);
c.waitFor(m->m.level.getBlockState(table.offset(1,0,2)).is(SuperPistons.NORMAL),100);
click(c,table.offset(1,0,2),false,true);
server.runOnServer(s->check(player(s).level().getBlockState(table.offset(1,0,2)).is(Blocks.PISTON),"Super piston still separates before editing its parts"));
// An isolated barrel and piston now use generic states instead of an incomplete-structure error.
server.runOnServer(s->{var p=player(s);p.teleportTo(.5,1,3.2);p.level().setBlockAndUpdate(TARGET,Blocks.BARREL.defaultBlockState());});c.waitFor(m->m.player.distanceToSqr(.5,1,3.2)<.1&&m.level.getBlockState(TARGET).is(Blocks.BARREL),100);
click(c,TARGET,false,false);server.runOnServer(s->check(player(s).level().getBlockState(TARGET).getValue(BarrelBlock.FACING)!=Blocks.BARREL.defaultBlockState().getValue(BarrelBlock.FACING),"Isolated barrel rotates"));
server.runOnServer(s->player(s).level().setBlockAndUpdate(TARGET,Blocks.PISTON.defaultBlockState()));c.waitFor(m->m.level.getBlockState(TARGET).is(Blocks.PISTON),100);
click(c,TARGET,false,false);server.runOnServer(s->check(player(s).level().getBlockState(TARGET).getValue(PistonBaseBlock.FACING)!=Blocks.PISTON.defaultBlockState().getValue(PistonBaseBlock.FACING),"Isolated piston rotates"));
server.runOnServer(s->player(s).level().setBlock(TARGET,stairs.setValue(StairBlock.HALF,Half.TOP),18));saved=world.getWorldSave();
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);world.getServer().runOnServer(s->{var l=player(s).level();check(WrenchTextures.rotation(l.getChunkAt(DIRT),DIRT)==1,"Server persisted dirt texture");check(l.getBlockState(TARGET).getValue(StairBlock.HALF)==Half.TOP,"Server persisted native edited state");check(((ChestBlockEntity)l.getBlockEntity(CHEST)).getItem(0).getCount()==23,"Saved chest keeps inventory");});
c.waitFor(m->WrenchTextureModels.rotation(DIRT)==1,150);c.getInput().lookAt(DIRT);c.takeScreenshot("wrench120-reconnect");
}
System.out.println("WRENCH120_PASS native creative/survival clicks, reverse, properties, textures and resource reload, unchanged geometry, filled chest, multiblock assembly priority, permissions, cleanup and save/reconnect");
}
static void click(ClientGameTestContext c,BlockPos pos,boolean left,boolean sneak){
if(sneak){c.getInput().holdKey(o->o.keyShift);c.waitTicks(3);}
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(pos.getX()+.5,pos.getY()+.3,pos.getZ()+.5)));c.waitTicks(3);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult hit&&hit.getBlockPos().equals(pos),"Native cursor hits target "+pos+" instead of "+m.hitResult));
if(left){c.getInput().holdKey(o->o.keyAttack);c.waitTicks(1);c.getInput().releaseKey(o->o.keyAttack);}else{c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);}
c.waitTicks(5);if(sneak){c.getInput().releaseKey(o->o.keyShift);c.waitTicks(3);}
}
private static List<float[]> geometry(Minecraft m,BlockPos pos){
var result=new ArrayList<float[]>();var state=m.level.getBlockState(pos);
var emitter=Renderer.get().quadEmitter(q->{var data=new float[20];for(int i=0;i<4;i++){data[i*5]=q.x(i);data[i*5+1]=q.y(i);data[i*5+2]=q.z(i);data[i*5+3]=q.u(i);data[i*5+4]=q.v(i);}result.add(data);});
m.getModelManager().getBlockStateModelSet().get(state).emitQuads(emitter,m.level,pos,state,RandomSource.create(state.getSeed(pos)),face->false);check(!result.isEmpty(),"Native model emits real textured geometry");return result;
}
private static void checkUvs(List<float[]> before,List<float[]> after){
check(before.size()==after.size(),"Same face count");boolean changed=false;
for(int q=0;q<before.size();q++)for(int v=0;v<4;v++){for(int axis=0;axis<3;axis++)check(before.get(q)[v*5+axis]==after.get(q)[v*5+axis],"Texture turn never moves geometry");changed|=before.get(q)[v*5+3]!=after.get(q)[v*5+3]||before.get(q)[v*5+4]!=after.get(q)[v*5+4];}
check(changed,"Dirt UVs actually change");
}
private static void checkGeometry(List<float[]> a,List<float[]> b,String message){check(a.size()==b.size(),message);for(int i=0;i<a.size();i++)check(Arrays.equals(a.get(i),b.get(i)),message);}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,111 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.event.player.*;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import net.minecraft.world.level.block.piston.PistonBaseBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.*;
import net.minecraft.world.phys.*;
/** Native golden-wrench clicks cannot create resources or split linked blocks. */
public final class Wrench123ClientChecks implements FabricClientGameTest {
private static final BlockPos TARGET=new BlockPos(0,1,0),CHEST=new BlockPos(2,1,0),DIRT=new BlockPos(-2,1,0);
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0));
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0));intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Wrench123 "+UUID.randomUUID());s.setSeed("123");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=player(s);var l=p.level();for(int x=-5;x<=8;x++)for(int z=-4;z<=6;z++)l.setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.teleportTo(.5,1,3.2);equip(p,Multiblocks.WRENCH);
l.setBlockAndUpdate(TARGET,Blocks.OAK_STAIRS.defaultBlockState());l.setBlockAndUpdate(CHEST,Blocks.CHEST.defaultBlockState());((ChestBlockEntity)l.getBlockEntity(CHEST)).setItem(0,new ItemStack(Items.DIAMOND,23));l.setBlockAndUpdate(DIRT,Blocks.DIRT.defaultBlockState());
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
});
c.waitFor(m->m.player.getMainHandItem().is(Multiblocks.WRENCH)&&m.player.distanceToSqr(.5,1,3.2)<.1&&m.level.getBlockState(TARGET).is(Blocks.OAK_STAIRS),150);
var stairs=c.computeOnClient(m->m.level.getBlockState(TARGET));
Wrench120ClientChecks.click(c,TARGET,false,false);server.runOnServer(s->check(player(s).level().getBlockState(TARGET).getValue(StairBlock.FACING)!=stairs.getValue(StairBlock.FACING),"Golden wrench changes native stair facing"));
Wrench120ClientChecks.click(c,TARGET,false,true);server.waitFor(s->player(s).level().getBlockState(TARGET)==stairs,100);
for(int i=0;i<5;i++){Wrench120ClientChecks.click(c,TARGET,true,false);Wrench120ClientChecks.click(c,TARGET,false,false);}
server.runOnServer(s->{var state=player(s).level().getBlockState(TARGET);check(!state.getValue(StairBlock.WATERLOGGED)&&state.getValue(StairBlock.HALF)==Half.BOTTOM&&state.getValue(StairBlock.SHAPE)==StairsShape.STRAIGHT,"Repeated native clicks never select water, half or fake stair shapes");});
for(int i=0;i<4;i++)Wrench120ClientChecks.click(c,CHEST,false,false);
server.runOnServer(s->{var p=player(s);var be=(ChestBlockEntity)p.level().getBlockEntity(CHEST);check(be.getItem(0).getCount()==23&&be.getBlockState()==Blocks.CHEST.defaultBlockState(),"Single chest remains single, dry and filled after four rotations");check(p.containerMenu==p.inventoryMenu,"Ordinary rotation does not open chest");});
Wrench120ClientChecks.click(c,DIRT,true,false);Wrench120ClientChecks.click(c,DIRT,false,false);
server.runOnServer(s->{var l=player(s).level();check(WrenchTextures.rotation(l.getChunkAt(DIRT),DIRT)==0&&l.getBlockState(DIRT).is(Blocks.DIRT),"Golden wrench never adds a texture transform");regressions(player(s));});
c.takeScreenshot("wrench123-golden-safe");
// Finding Steve's wrench grants its full powers in ordinary survival, without operator status.
server.runOnServer(s->{var p=player(s);p.setGameMode(GameType.SURVIVAL);s.getWorldData().setAllowCommands(false);equip(p,Multiblocks.STEVE_WRENCH);});
c.waitFor(m->!m.player.isCreative()&&m.player.getMainHandItem().is(Multiblocks.STEVE_WRENCH),100);
Wrench120ClientChecks.click(c,DIRT,true,false);Wrench120ClientChecks.click(c,DIRT,false,false);
server.waitFor(s->WrenchTextures.rotation(player(s).level().getChunkAt(DIRT),DIRT)==1,100);c.waitFor(m->WrenchTextureModels.rotation(DIRT)==1,100);
Wrench120ClientChecks.click(c,TARGET,false,false);Wrench120ClientChecks.click(c,TARGET,true,false);Wrench120ClientChecks.click(c,TARGET,false,false);
server.runOnServer(s->{var p=player(s);check(p.level().getBlockState(TARGET).getValue(StairBlock.HALF)==Half.TOP,"Native Steve edit works for ordinary survival without commands");
for(var hand:InteractionHand.values()){
p.setItemInHand(hand,new ItemStack(Multiblocks.STEVE_WRENCH));check(GoldenWrench.allowed(p,TARGET,hand,Direction.UP),"Survival use allowed in both hands");
GoldenWrench.change(p,DIRT,hand,Direction.UP);
}
check(WrenchTextures.rotation(p.level().getChunkAt(DIRT),DIRT)==3,"Both hands can freely edit textures in survival");
p.setItemInHand(InteractionHand.OFF_HAND,ItemStack.EMPTY);p.setGameMode(GameType.CREATIVE);check(GoldenWrench.allowed(p,TARGET,InteractionHand.MAIN_HAND,Direction.UP),"Creative access without commands enabled");
p.setGameMode(GameType.ADVENTURE);check(!GoldenWrench.allowed(p,TARGET,InteractionHand.MAIN_HAND,Direction.UP),"Adventure restrictions remain");p.setGameMode(GameType.SPECTATOR);check(!GoldenWrench.allowed(p,TARGET,InteractionHand.MAIN_HAND,Direction.UP),"Spectator cannot edit");p.setGameMode(GameType.CREATIVE);
var steve=new ItemStack(Multiblocks.STEVE_WRENCH);check(Boolean.TRUE.equals(steve.get(DataComponents.ENCHANTMENT_GLINT_OVERRIDE)),"Distinct enchanted Steve item");
});
c.waitFor(m->m.player.isCreative(),100);c.takeScreenshot("wrench123-steve-texture");
System.out.println("WRENCH123_SAFE_PASS native clicks, water/inventory preservation, linked blocks, real neighbour shapes, support checks, ordinary survival and creative use, adventure/spectator restrictions and both hands");
}
// Keep the full former debug-stick contract on Steve, including UVs/save/reload, and assembly on gold.
new Wrench120ClientChecks().runTest(c);
System.out.println("WRENCH123_PASS safe golden wrench and Steve wrench with native regression coverage");
}
private static void regressions(ServerPlayer p){
var l=p.level();
// Water stays present when rotating a legitimately waterlogged block.
l.setBlock(TARGET,Blocks.OAK_STAIRS.defaultBlockState().setValue(StairBlock.WATERLOGGED,true),18);
use(p,TARGET);check(l.getBlockState(TARGET).getValue(StairBlock.WATERLOGGED),"Existing water is preserved");
l.setBlock(TARGET,Blocks.OAK_STAIRS.defaultBlockState().setValue(StairBlock.FACING,Direction.WEST).setValue(StairBlock.SHAPE,StairsShape.INNER_LEFT),18);
use(p,TARGET);check(l.getBlockState(TARGET).getValue(StairBlock.SHAPE)==StairsShape.STRAIGHT&&!l.getBlockState(TARGET).getValue(StairBlock.WATERLOGGED),"Native shape recomputed against actual empty neighbours");
var a=CHEST;var b=CHEST.east();var base=Blocks.CHEST.defaultBlockState().setValue(ChestBlock.FACING,Direction.NORTH);
l.setBlock(a,base.setValue(ChestBlock.TYPE,ChestType.LEFT),18);l.setBlock(b,base.setValue(ChestBlock.TYPE,ChestType.RIGHT),18);
var first=(ChestBlockEntity)l.getBlockEntity(a);var second=(ChestBlockEntity)l.getBlockEntity(b);first.setItem(0,new ItemStack(Items.DIAMOND,23));second.setItem(0,new ItemStack(Items.EMERALD,17));
var left=l.getBlockState(a);var right=l.getBlockState(b);use(p,a);use(p,b);
check(l.getBlockState(a)==left&&l.getBlockState(b)==right&&l.getBlockEntity(a)==first&&l.getBlockEntity(b)==second&&first.getItem(0).getCount()==23&&second.getItem(0).getCount()==17,"Double chest remains linked with both original inventories");
var q=TARGET;var door=Blocks.OAK_DOOR.defaultBlockState();l.setBlock(q,door.setValue(DoorBlock.HALF,DoubleBlockHalf.LOWER),18);l.setBlock(q.above(),door.setValue(DoorBlock.HALF,DoubleBlockHalf.UPPER),18);
var lower=l.getBlockState(q);var upper=l.getBlockState(q.above());use(p,q);use(p,q.above());check(l.getBlockState(q)==lower&&l.getBlockState(q.above())==upper,"Both door halves protected");l.setBlock(q.above(),Blocks.AIR.defaultBlockState(),18);
var bed=Blocks.BED.red().defaultBlockState();l.setBlock(q,bed,18);use(p,q);check(l.getBlockState(q)==bed,"Bed half protected even if malformed already");
var extended=Blocks.PISTON.defaultBlockState().setValue(PistonBaseBlock.EXTENDED,true);l.setBlock(q,extended,18);use(p,q);check(l.getBlockState(q)==extended,"Extended piston protected");
for(var block:List.of(Blocks.PISTON_HEAD,Blocks.MOVING_PISTON)){l.setBlock(q,block.defaultBlockState(),18);use(p,q);check(l.getBlockState(q)==block.defaultBlockState(),"Attached/moving piston part protected");}
l.setBlock(q,Blocks.OAK_LOG.defaultBlockState(),18);var axes=EnumSet.noneOf(Direction.Axis.class);for(int i=0;i<3;i++){use(p,q);axes.add(l.getBlockState(q).getValue(RotatedPillarBlock.AXIS));}check(axes.size()==3,"All three native log axes supported");
l.setBlock(q,Blocks.PISTON.defaultBlockState(),18);var facings=EnumSet.noneOf(Direction.class);for(int i=0;i<6;i++){use(p,q);facings.add(l.getBlockState(q).getValue(PistonBaseBlock.FACING));}check(facings.size()==6,"Isolated piston supports all six native orientations");
// Turning a wall torch toward empty air must not break it or its support.
l.setBlock(q.north(),Blocks.STONE.defaultBlockState(),18);var torch=Blocks.WALL_TORCH.defaultBlockState().setValue(WallTorchBlock.FACING,Direction.SOUTH);l.setBlock(q,torch,18);use(p,q);check(l.getBlockState(q)==torch,"Unsupported rotation leaves wall-mounted block intact");l.setBlock(q.north(),Blocks.AIR.defaultBlockState(),18);
l.setBlock(q,Blocks.OAK_STAIRS.defaultBlockState(),18);
var top=GoldenWrench.cycle(l.getBlockState(q),StairBlock.HALF,false);check(top.getValue(StairBlock.HALF)==Half.TOP,"Steve still has native debug property cycle");
// Existing visual edits are preserved; the ordinary key cannot create or reset them.
WrenchTextures.turn(l,q,1);use(p,q);check(WrenchTextures.rotation(l.getChunkAt(q),q)==1,"Golden rotation preserves saved Steve texture data");WrenchTextures.turn(l,q,-1);
}
private static void use(ServerPlayer p,BlockPos pos){UseBlockCallback.EVENT.invoker().interact(p,p.level(),InteractionHand.MAIN_HAND,new BlockHitResult(Vec3.atCenterOf(pos),Direction.UP,pos,false));}
private static void equip(ServerPlayer p,Item item){p.getInventory().setItem(p.getInventory().getSelectedSlot(),new ItemStack(item));p.inventoryMenu.broadcastFullState();}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,206 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import java.nio.file.Files;
import java.util.ArrayList;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import org.joml.Matrix4f;
import org.joml.Vector4f;
/** Native GPU checks sharing the isolated flat-world fixture and its preference backup. */
final class EdgeHighlights129ClientChecks {
static void run(ClientGameTestContext c, TestServerContext server) {
c.runOnClient(m -> preferences());
server.runOnServer(s -> {
for (int x=10;x<=14;x++) for (int z=6;z<=10;z++) {
s.overworld().setBlockAndUpdate(new BlockPos(x,65,z),
(Math.floorMod(x,8)<4 ? Blocks.STONE : Blocks.OAK_PLANKS).defaultBlockState());
}
});
camera(c,server,12.5,72,8.5,60);
c.waitTicks(35);
shadows(c,false);
edge(c,false);
var off=capture(c,"edges-flat-off");
c.waitTicks(6);
unchanged(off,capture(c,null),"Highlight OFF control is stable");
edge(c,true);
var on=capture(c,"edges-flat-on-30");
var low=measure(off,on);
check(low.brighter>100 && low.stable>low.samples*.55 && low.darker<low.samples*.005,
"Highlight brightens local strips, preserves face interiors: "+low);
grid(c,off,on);
for (int distance : new int[]{16,32,64,128,256}) {
c.runOnClient(m -> VanillaLight.setEdgeDistance(distance));
frames(c);
check(c.computeOnClient(m -> VanillaLight.edgeDistance())==distance,"All CTM ranges are available");
unchanged(on,capture(c,null),"Near CTM contours unchanged at range "+distance);
}
c.runOnClient(m -> VanillaLight.setEdgeDistance(32));
c.runOnClient(m -> {
check(PixelShadows.shadowMap()==null,"Highlight works without allocating a cast-shadow map");
VanillaLight.setEdgeIntensity(90);
});
frames(c);
var high=measure(off,capture(c,"edges-flat-on-90"));
check(high.meanGain>low.meanGain*1.8,"Intensity changes visible brightness: low="+low+" high="+high);
c.runOnClient(m -> VanillaLight.setEdgeIntensity(0));
c.waitTicks(6);
c.runOnClient(m -> check(!EdgeHighlights.applied() && !EdgeHighlights.allocated(),"Zero intensity frees its uniform buffer"));
unchanged(off,capture(c,"edges-zero"),"Zero intensity reproduces OFF exactly");
c.runOnClient(m -> VanillaLight.setEdgeIntensity(30));
frames(c);
camera(c,server,11.99999,72,8.5,60);
var anchor=capture(c,null);
camera(c,server,12.00001,72,8.5,60);
unchanged(anchor,capture(c,"edges-anchor-crossing"),"Grid remains stable across an integer camera anchor");
camera(c,server,12.5,72,8.5,60);
unchanged(on,capture(c,null),"Returning to the camera restores the same grid");
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);
c.waitFor(m -> reload.isDone() && m.gui.overlay()==null,1200);
reload.join();
c.runOnClient(m -> {
forgetPreferences();
check(VanillaLight.edgeHighlights() && VanillaLight.edgeIntensity()==30,"Highlight preferences survive reload and disk reread");
});
frames(c);
unchanged(on,capture(c,"edges-after-reload"),"Resource reload retains the highlight image");
edge(c,false);
unchanged(off,capture(c,"edges-off-restored"),"Disabling leaves no residual highlight");
// A new hole creates a connected boundary immediately; filling it removes that boundary.
server.runOnServer(s -> s.overworld().setBlockAndUpdate(new BlockPos(12,65,8),Blocks.AIR.defaultBlockState()));
c.waitTicks(20);
var holeOff=capture(c,null);
edge(c,true);
var hole=measure(holeOff,capture(c,"edges-ctm-hole"));
check(hole.brighter>low.brighter+20,"Removing an interior block creates a new CTM contour");
server.runOnServer(s -> s.overworld().setBlockAndUpdate(new BlockPos(12,65,8),Blocks.STONE.defaultBlockState()));
c.waitTicks(20);
unchanged(on,capture(c,null),"Filling the hole reconnects the flat surface");
// The actual radius gates a visible contour, independently of shadow range.
camera(c,server,12.5,88,8.5,30);
edge(c,false);
var farOff=capture(c,null);
c.runOnClient(m -> VanillaLight.setEdgeDistance(16));
edge(c,true);
unchanged(farOff,capture(c,"edges-beyond-16"),"CTM beyond selected 16-block radius is absent");
c.runOnClient(m -> {VanillaLight.setEdgeDistance(64);VanillaLight.setEdgeIntensity(90);});
frames(c);
var far=measure(farOff,capture(c,"edges-inside-64"));
check(far.brighter>40,"Extending the CTM radius restores a distant contour: "+far);
c.runOnClient(m -> {VanillaLight.setEdgeDistance(32);VanillaLight.setEdgeIntensity(30);});
edge(c,false);
server.runOnServer(s -> {
for (int x=9;x<=13;x+=2) for(int y=65;y<=67;y++)
s.overworld().setBlockAndUpdate(new BlockPos(x,y,12),
(x==9 ? Blocks.CONCRETE.red() : x==11 ? Blocks.STONE_BRICKS : Blocks.OAK_PLANKS).defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(10,65,11),Blocks.STONE_SLAB.defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(12,65,11),Blocks.OAK_STAIRS.defaultBlockState());
});
camera(c,server,12.5,72,7.5,70);
look(c,server,15,55);
shadows(c,true);
var reliefOff=capture(c,"edges-relief-with-shadows-off");
edge(c,true);
var reliefOn=capture(c,"edges-relief-with-shadows-on");
var relief=measure(reliefOff,reliefOn);
check(relief.brighter>100 && relief.stable>relief.samples*.5 && relief.darker<relief.samples*.01,
"Highlight composes with cast shadows and coloured textured blocks: "+relief);
look(c,server,15.00002f,55.00002f);
unchanged(reliefOn,capture(c,null),"Tiny oblique camera rotation keeps highlights stable");
c.runOnClient(m -> VanillaLight.setEnabled(false));
c.waitTicks(6);
c.runOnClient(m -> check(!EdgeHighlights.applied() && !EdgeHighlights.allocated(),"Main shader OFF also releases highlight resources"));
c.runOnClient(m -> VanillaLight.setEnabled(true));
frames(c);
edge(c,false);
System.out.println("EDGE_HIGHLIGHTS129_PASS connected contours and unchanged flat joins; independent shadows; intensity; zero/OFF release; "
+"camera anchors; oblique textured scene; resource reload; legacy preference migration; low="+low+" high="+high+" relief="+relief);
}
private static void preferences() {
var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");
try {
// Real prior-version JSON, without either new field. Preserve its existing settings.
Files.writeString(path,"{\"enabled\":true,\"saturation\":100,\"pixelShadows\":true,\"shadowPixels\":16,\"shadowDistance\":32}");
forgetPreferences();
check(!VanillaLight.edgeHighlights() && VanillaLight.edgeIntensity()==30 && VanillaLight.edgeDistance()==32,"Old configuration defaults highlights OFF at intensity 30");
check(VanillaLight.pixelShadows() && VanillaLight.saturation()==100,"Migration retains existing shader choices");
for(int value:new int[]{-100,0,30,100,1000}) {
VanillaLight.setEdgeIntensity(value);forgetPreferences();
check(VanillaLight.edgeIntensity()==Math.clamp(value,0,100),"Saved intensity is bounded");
}
for(int[] pair:new int[][]{{-1,16},{24,32},{48,64},{96,128},{192,256},{9999,256}}) {
VanillaLight.setEdgeDistance(pair[0]);forgetPreferences();
check(VanillaLight.edgeDistance()==pair[1],"Saved CTM distance is bounded and normalized");
}
VanillaLight.setEdgeDistance(32);
VanillaLight.setEdgeIntensity(30);
VanillaLight.setEdgeHighlights(true);forgetPreferences();
check(VanillaLight.edgeHighlights(),"Toggle persists independently");
VanillaLight.setEdgeHighlights(false);
} catch(java.io.IOException e) { throw new AssertionError(e); }
}
private static void edge(ClientGameTestContext c,boolean enabled) {
c.runOnClient(m -> VanillaLight.setEdgeHighlights(enabled));
if(enabled)frames(c);
else {
c.waitTicks(6);
c.runOnClient(m -> check(!EdgeHighlights.applied() && !EdgeHighlights.allocated(),"OFF frees resources"));
}
}
private static void frames(ClientGameTestContext c) {
int before=c.computeOnClient(m -> EdgeHighlights.renderedFrames());
c.waitFor(m -> EdgeHighlights.applied() && EdgeHighlights.renderedFrames()>before+8,200);
c.waitTicks(6);
}
private record Difference(int samples,int brighter,int stable,int darker,double meanGain) { }
private static Difference measure(Frame off,Frame on) {
int samples=0,brighter=0,stable=0,darker=0;double gain=0;
for(int y=off.height()/10;y<off.height()*9/10;y+=2) for(int x=off.width()/10;x<off.width()*9/10;x+=2) {
int i=y*off.width()+x,a=off.pixels()[i],b=on.pixels()[i];
int delta=brightness(b)-brightness(a);samples++;gain+=delta;
if(delta>=4)brighter++;
if(delta<=-4)darker++;
if(channelDifference(a,b)<=2)stable++;
}
var result=new Difference(samples,brighter,stable,darker,gain/samples);
System.out.println("EDGE_HIGHLIGHTS129_PIXELS "+result);
return result;
}
private static void grid(ClientGameTestContext c,Frame off,Frame on) {
var samples=c.computeOnClient(m -> {
var camera=m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
var vp=new Matrix4f(camera.projectionMatrix).mul(camera.viewRotationMatrix);
var result=new ArrayList<int[]>();
for(int x=11;x<=14;x++) for(int z=6;z<=10;z++) for(int edge=0;edge<2;edge++) {
var p=vp.transform(new Vector4f((float)(x+(edge==0?.5:1.0/32)-camera.pos.x),
(float)(66-camera.pos.y),(float)(z+.5-camera.pos.z),1));
int px=(int)((p.x/p.w*.5+.5)*off.width());
int py=(int)((.5-p.y/p.w*.5)*off.height());
if(px>=10 && px<off.width()-10 && py>=10 && py<off.height()-10)result.add(new int[]{py*off.width()+px,edge});
}
return result;
});
int centers=0,unchanged=0,edges=0,bright=0;
for(var sample:samples) {
int a=off.pixels()[sample[0]],b=on.pixels()[sample[0]];
if(sample[1]==0){centers++;if(channelDifference(a,b)<=2)unchanged++;}
else {edges++;if(brightness(b)-brightness(a)>=4)bright++;}
}
check(centers>=15 && unchanged>centers*.95 && edges>=15 && bright==0,
"Connected block centers and coplanar joins remain untouched: centers="+unchanged+"/"+centers+" edges="+bright+"/"+edges);
System.out.println("EDGE_HIGHLIGHTS129_GRID centers="+unchanged+"/"+centers+" edges="+bright+"/"+edges);
}
}
@@ -0,0 +1,642 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.HelloWorldScreen;
import fr.koka.sanctuary.client.IntroClient;
import fr.koka.sanctuary.client.IntroSpawnFlash;
import fr.koka.sanctuary.client.ProgressionClient;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.io.IOException;
import java.nio.file.Files;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.CameraType;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.renderer.culling.Frustum;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.StairBlock;
import net.minecraft.world.level.gamerules.GameRules;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
/** Real terrain/depth-map checks on a new flat seed 122 world; GPU readback is test-only. */
public final class PixelShadows122ClientChecks implements FabricClientGameTest {
private static final int FLOOR = 64;
private static final AABB PILLAR = new AABB(0, FLOOR + 1, 0, 2, FLOOR + 9, 2);
private static final BlockPos SLAB = new BlockPos(5, FLOOR + 3, -5);
private static final BlockPos STAIR = new BlockPos(5, FLOOR + 3, 5);
private static final int DARK_DELTA = 8;
@Override
public void runTest(ClientGameTestContext c) {
var path = FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");
final byte[] originalPreferences;
try {
originalPreferences = Files.exists(path) ? Files.readAllBytes(path) : null;
} catch (IOException e) {
throw new AssertionError("Cannot back up shader preferences", e);
}
var originalOptions = c.computeOnClient(Options::capture);
var onboarding = new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m -> {
if (onboarding.get() && m.gui.screen() instanceof HelloWorldScreen screen) {
// Public test support works across packages; enter through the native widgets.
IntroClient.setQuickTestIntro(true);
if (!screen.starterChoices().isEmpty()) Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getClass().getSimpleName().equals("ColorButton"))
.findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getMessage().getContents() instanceof TranslatableContents t
&& t.getKey().equals("sanctuary.progression.enter"))
.findFirst().orElseThrow().onPress(enter());
onboarding.set(false);
}
});
try {
c.runOnClient(m -> {
m.options.renderDistance().set(6);
m.options.simulationDistance().set(5);
m.options.bobView().set(false);
m.options.pauseOnLostFocus = false;
m.options.setCameraType(CameraType.FIRST_PERSON);
checkPreferences();
});
try (var world = c.worldBuilder().adjustSettings(s -> {
s.setName("PixelShadows122 " + UUID.randomUUID());
s.setSeed("122");
s.setAllowCommands(true);
s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);
s.setWorldType(s.getNormalPresetList().stream().filter(e -> e.preset() != null
&& e.preset().getRegisteredName().equals("sanctuary_test:flat"))
.findFirst().orElseThrow());
}).create()) {
world.getConnection().waitForChunksDownload(2400);
c.waitFor(m -> m.player != null && ProgressionClient.active()
&& m.gui.screen() == null && !IntroSpawnFlash.active(), 1200);
c.getInput().pressKey(InputConstants.KEY_F1);
var server = world.getServer();
server.runOnServer(s -> {
command(s, "sanctuary time vanilla");
command(s, "sanctuary weather vanilla");
command(s, "weather clear 100000");
if (s.getGlobalGameRules().get(GameRules.ADVANCE_TIME))
command(s, "gamerule " + GameRules.ADVANCE_TIME.id() + " false");
check(!s.getGlobalGameRules().get(GameRules.ADVANCE_TIME), "Native daylight rule is frozen");
command(s, "time set 3000");
command(s, "gamemode spectator @a");
if (s.getGlobalGameRules().get(GameRules.SPAWN_MOBS))
command(s, "gamerule " + GameRules.SPAWN_MOBS.id() + " false");
var level = s.overworld();
var entities = new java.util.ArrayList<net.minecraft.world.entity.Entity>();
level.getAllEntities().forEach(entities::add);
entities.stream().filter(e -> !(e instanceof net.minecraft.server.level.ServerPlayer))
.forEach(net.minecraft.world.entity.Entity::discard);
// The elevated platform isolates the samples from grass, mobs and flat-world lighting.
for (int x = -24; x <= 24; x++) for (int z = -24; z <= 24; z++) {
level.setBlockAndUpdate(new BlockPos(x, FLOOR, z), Blocks.CONCRETE.white().defaultBlockState());
}
pillar(s, true);
// Coloured casters cannot be mistaken for neutral white receiving ground.
level.setBlockAndUpdate(SLAB, Blocks.RED_SANDSTONE_SLAB.defaultBlockState());
level.setBlockAndUpdate(STAIR, Blocks.RED_SANDSTONE_STAIRS.defaultBlockState()
.setValue(StairBlock.FACING, Direction.WEST));
});
camera(c, server, -2, FLOOR + 18, 1, 70);
c.waitFor(m -> m.level.getBlockState(new BlockPos(0, FLOOR + 8, 0)).is(Blocks.CONCRETE.red())
&& m.level.getBlockState(SLAB).is(Blocks.RED_SANDSTONE_SLAB)
&& m.level.getBlockState(STAIR).is(Blocks.RED_SANDSTONE_STAIRS), 200);
c.waitFor(m -> {
for(int x=-24;x<=24;x+=8) for(int z=-24;z<=24;z+=8)
if(!m.level.getBlockState(new BlockPos(x,FLOOR,z)).is(Blocks.CONCRETE.white()))return false;
return m.levelRenderer.hasRenderedAllSections();
},1200);
c.waitTicks(40);
c.runOnClient(m -> check(VanillaLight.active(), "Native Sanctuary shader is active without an external engine"));
float morning = c.computeOnClient(m -> m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);
// Keep VanillaLight enabled for both frames: changing its fog/grade would invalidate this oracle.
shadows(c, false);
var off = capture(c, "day-off");
c.waitTicks(6);
unchanged(off, capture(c, null), "Fixed camera/daylight OFF control");
shadows(c, true);
var on = capture(c, "day-on-pillar-slab-stair");
var day = compare(off, on);
localShadow(day, "Pillar/slab/stair daylight ground", .70);
c.runOnClient(m -> {
check(Float.compare(morning, m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle) == 0,
"Native rule actually freezes the rendered sun angle");
check(PixelShadows.shadowMap() != null, "Daylight allocates a real shadow target");
check(PixelShadows.shadowMap().width == PixelShadows.mapSize(32, 16), "Shadow map follows selected quality");
});
for (int pixels : new int[]{8, 32, 16}) {
c.runOnClient(m -> VanillaLight.setShadowPixels(pixels));
activeFrames(c);
c.runOnClient(m -> check(PixelShadows.shadowMap().width == PixelShadows.mapSize(32, pixels),
"Live pixel density reallocates the correct depth map"));
capture(c, "precision-" + pixels);
}
for (int distance : new int[]{16, 64, 128, 256, 32}) {
c.runOnClient(m -> VanillaLight.setShadowDistance(distance));
activeFrames(c);
c.runOnClient(m -> check(PixelShadows.shadowMap().width == PixelShadows.mapSize(distance, 16),
"Live distance reallocates the correct depth map"));
}
shadows(c, false);
unchanged(off, capture(c, "day-off-restored"), "Disabling removes the shadow without residual grading");
// Aim at the shadow's far end, with its entire caster outside the actual camera frustum.
shadows(c, true);
double shadowX = c.computeOnClient(m -> {
var sun = PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);
check(sun.y > .2f && Math.abs(sun.x / sun.y) > .5f, "Morning has an oblique sun for offscreen casting");
return 1.0 - 7.0 * sun.x / sun.y;
});
camera(c, server, shadowX, FLOOR + 5, 1, 30);
c.runOnClient(m -> {
var state = m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
var frustum = new Frustum(state.viewRotationMatrix, state.projectionMatrix);
frustum.prepare(state.pos.x, state.pos.y, state.pos.z);
check(!frustum.isVisible(PILLAR), "Whole pillar is outside the receiver camera frustum");
check(!frustum.isVisible(new AABB(SLAB)) && !frustum.isVisible(new AABB(STAIR)),
"Slab and stair are also outside the offscreen capture");
});
shadows(c, false);
var offscreenOff = capture(c, "offscreen-off");
shadows(c, true);
var offscreenOn = capture(c, "offscreen-on");
localShadow(compare(offscreenOff, offscreenOn), "Offscreen pillar still casts onto visible ground", .20);
// Do not toggle/release/rebuild the renderer here: real server edits must invalidate its live cache.
server.runOnServer(s -> pillar(s, false));
c.waitFor(m -> m.level.getBlockState(new BlockPos(0, FLOOR + 8, 0)).isAir(), 200);
activeFrames(c);
var removed = capture(c, "offscreen-caster-removed");
restoredGround(offscreenOff, offscreenOn, removed);
server.runOnServer(s -> pillar(s, true));
c.waitFor(m -> m.level.getBlockState(new BlockPos(0, FLOOR + 8, 0)).is(Blocks.CONCRETE.red()), 200);
activeFrames(c);
var replaced = capture(c, "offscreen-caster-replaced");
localShadow(compare(offscreenOff, replaced), "Replacing caster restores its shadow", .20);
unchanged(offscreenOn, replaced, "Same caster restores the same ground shadow");
// beta.128: the moon now casts a weaker shadow using its native celestial angle.
server.runOnServer(s -> command(s, "time set 15000"));
c.waitFor(m -> PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle).y < 0, 200);
camera(c, server, -2, FLOOR + 18, 1, 70);
shadows(c, false);
var moonOff = capture(c, "moon-off");
shadows(c, true);
var moonOn = capture(c, "moon-on");
var moonStats = opacity(moonOff, moonOn);
check(moonStats.dark > 100 && moonStats.maximum > .025 && moonStats.maximum < .14,
"Moon creates localized subtle shadows: " + moonStats);
unchanged(moonOn, capture(c, null), "Frozen moon shadow is stable");
server.runOnServer(s -> command(s, "time set 111000"));
c.waitFor(m -> m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.moonPhase.index() == 4
&& !PixelShadows.applied(), 200);
c.waitTicks(6);
int newMoonFrames = c.computeOnClient(m -> PixelShadows.renderedFrames());
c.waitTicks(6);
c.runOnClient(m -> check(PixelShadows.renderedFrames() == newMoonFrames, "New moon submits no shadow pass"));
noonAndFoliage(c, server);
c.waitFor(m -> PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle).y > .2f,
200);
shadows(c, true);
camera(c, server, -2, FLOOR + 18, 1, 70);
c.waitTicks(30);
var beforeReload = capture(c, "before-reload");
var reload = c.computeOnClient(Minecraft::reloadResourcePacks);
c.waitFor(m -> reload.isDone() && m.gui.overlay() == null, 1200);
reload.join();
c.runOnClient(m -> {
forgetPreferences();
check(VanillaLight.enabled() && VanillaLight.pixelShadows()
&& VanillaLight.shadowPixels() == 16 && VanillaLight.shadowDistance() == 32,
"Resource reload and disk reread retain enabled shadow preferences");
});
activeFrames(c);
unchanged(beforeReload, capture(c, "after-reload"), "Resource reload preserves the rendered ground shadow");
RealtimeShadows129ClientChecks.run(c, server);
EdgeHighlights129ClientChecks.run(c, server);
c.getInput().pressKey(InputConstants.KEY_F1);
for (String locale : new String[]{"fr_fr", "en_us"}) {
c.runOnClient(m -> {
m.getLanguageManager().setSelected(locale);
m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
for (String key : new String[]{"pixel_shadows", "shadow_pixels", "shadow_distance", "edge_highlights", "edge_intensity", "edge_distance"}) {
String fullKey = "sanctuary.shaders." + key;
check(!Component.translatable(fullKey).getString().equals(fullKey), "Translated " + locale + " " + key);
}
m.gui.setScreen(new ShaderOptionsScreen(null));
widgets(m.gui.screen()).filter(net.minecraft.client.gui.components.CycleButton.class::isInstance)
.map(net.minecraft.client.gui.components.AbstractWidget.class::cast).forEach(button -> {
check(m.font.width(button.getMessage()) < button.getWidth() - 8,
"Localized shader cycle fits: " + button.getMessage().getString());
});
});
c.waitTicks(3);
c.takeScreenshot("sanctuary-beta129-shadows-options-" + locale);
}
c.runOnClient(m -> m.gui.setScreen(null));
}
System.out.println("PIXEL_SHADOWS122_CLIENT_PASS: bounded saved preferences; native GPU ON/OFF ground; "
+ "offscreen caster; live removal/replacement; subtle moon; full-strength noon; foliage density; camera stability; resource reload; FR/EN screenshots");
} finally {
onboarding.set(false);
c.runOnClient(m -> {
PixelShadows.release();
originalOptions.restore(m);
IntroClient.setQuickTestIntro(false);
try {
if (originalPreferences == null) Files.deleteIfExists(path);
else Files.write(path, originalPreferences);
} catch (IOException e) {
throw new AssertionError("Cannot restore shader preferences", e);
} finally {
forgetPreferences();
}
});
}
}
private static void noonAndFoliage(ClientGameTestContext c, TestServerContext server) {
// Overhead caster keeps a visible noon shadow while the camera sits below it.
server.runOnServer(s -> {
pillar(s, false);
s.overworld().setBlockAndUpdate(SLAB, Blocks.AIR.defaultBlockState());
s.overworld().setBlockAndUpdate(STAIR, Blocks.AIR.defaultBlockState());
canopy(s, 1, false, 3);
command(s, "time set 6000");
});
camera(c, server, .99999, FLOOR + 5, 1, 70);
c.waitTicks(30);
shadows(c, false);
var noonOff = capture(c, "noon-off");
shadows(c, true);
var noonOn = capture(c, "noon-on");
var noonStats = opacity(noonOff, noonOn);
crispEdge(noonOff, noonOn);
check(noonStats.dark > 100 && noonStats.maximum > .34 && noonStats.maximum < .42,
"Noon shadow keeps its full daytime strength: " + noonStats);
camera(c, server, 1.00001, FLOOR + 5, 1, 70);
unchanged(noonOn, capture(c, "noon-camera-subpixel"), "Noon tiny motion across former integer anchor");
camera(c, server, .99999, FLOOR + 5, 1, 70);
unchanged(noonOn, capture(c, null), "Noon camera return");
look(c, server, 35, 60);
shadows(c, false);
var obliqueOff = capture(c, "noon-oblique-off");
shadows(c, true);
var obliqueNoon = capture(c, "noon-oblique");
localShadow(compare(obliqueOff, obliqueNoon), "Oblique noon camera sees actual white receiving ground", .10);
look(c, server, 35.00002f, 60.00002f);
unchanged(obliqueNoon, capture(c, null), "Subpixel rotation with an oblique receiver");
// Both sides of noon, frozen individually: no frame-to-frame edge flicker.
for (int time : new int[]{5990, 6010}) {
server.runOnServer(s -> command(s, "time set " + time));
c.waitTicks(10);
var stable = capture(c, "noon-" + time);
c.waitTicks(10);
unchanged(stable, capture(c, null), "Frozen near-noon " + time);
}
server.runOnServer(s -> command(s, "time set 5999"));
c.waitTicks(10);
var beforeNoon = capture(c, null);
server.runOnServer(s -> command(s, "time set 6001"));
c.waitTicks(10);
unchanged(beforeNoon, capture(c, "noon-crossing"), "Continuous passage through noon has no large flip");
server.runOnServer(s -> {
canopy(s, 1, true, 16);
command(s, "time set 3000");
});
camera(c, server, 0, FLOOR + 5, 0, 60);
c.waitTicks(30);
shadows(c, false);
var thinOff = capture(c, "leaves-thin-off");
shadows(c, true);
var thinOn = capture(c, "leaves-thin-on");
var thin = opacity(thinOff, thinOn);
c.waitTicks(8);
unchanged(thinOn, capture(c, null), "Leaf noise is independent of frame/time");
// Keep the live cache: adding inner leaf layers must invalidate it too.
server.runOnServer(s -> canopy(s, 4, true, 16));
c.waitTicks(30);
activeFrames(c);
var thickOn = capture(c, "leaves-thick-on");
shadows(c, false);
var thickOff = capture(c, "leaves-thick-off");
var thick = opacity(thickOff, thickOn);
server.runOnServer(s -> canopy(s, 4, false, 16));
c.waitTicks(30);
var solidOff = capture(c, "canopy-solid-off");
shadows(c, true);
var solid = opacity(solidOff, capture(c, "canopy-solid-on"));
check(thin.mean > .02 && thick.mean > thin.mean + .025 && solid.mean > thick.mean + .01,
"Depth increases foliage opacity, bounded by solid: thin=" + thin + " thick=" + thick + " solid=" + solid);
check(thin.dark > 100 && thin.lit > 100, "Thin foliage has both light holes and shadows: " + thin);
server.runOnServer(s -> {
for (int x=-16;x<=16;x++) for(int z=-16;z<=16;z++) for(int y=FLOOR+10;y<FLOOR+13;y++)
s.overworld().setBlockAndUpdate(new BlockPos(x,y,z), Blocks.AIR.defaultBlockState());
canopy(s, 1, true, 16);
});
c.waitTicks(30);
// Crossing a 16-block origin boundary must preserve the same world shadow.
camera(c, server, 7.99999, FLOOR + 5, 0, 60);
activeFrames(c);
var boundary = capture(c, null);
camera(c, server, 8.00001, FLOOR + 5, 0, 60);
activeFrames(c);
unchanged(boundary, capture(c, "anchor-boundary"), "Subpixel motion across 16-block anchor");
System.out.println("SHADOWS128_PASS moon/noon plus stable anchors; foliage thin=" + thin + " thick=" + thick + " solid=" + solid);
server.runOnServer(s -> {
for (int x=-16;x<=16;x++) for(int z=-16;z<=16;z++) for(int y=FLOOR+9;y<FLOOR+13;y++)
s.overworld().setBlockAndUpdate(new BlockPos(x,y,z), Blocks.AIR.defaultBlockState());
pillar(s, true);
});
}
private static void canopy(MinecraftServer s, int layers, boolean leaves, int radius) {
var state = leaves ? Blocks.OAK_LEAVES.defaultBlockState().setValue(net.minecraft.world.level.block.LeavesBlock.PERSISTENT, true)
: Blocks.CONCRETE.red().defaultBlockState();
for (int x=-radius;x<=radius;x++) for(int z=-radius;z<=radius;z++) for(int y=FLOOR+9;y<FLOOR+9+layers;y++)
s.overworld().setBlockAndUpdate(new BlockPos(x,y,z), state);
}
private static void crispEdge(Frame off, Frame on) {
int samples = 0, intermediate = 0;
for (int y=off.height/10;y<off.height*9/10;y+=2) for (int x=off.width/10;x<off.width*9/10;x+=2) {
int i=y*off.width+x;
if (!whiteGround(off.pixels[i])) continue;
samples++;
double ratio=(brightness(off.pixels[i])-brightness(on.pixels[i]))/(double)brightness(off.pixels[i]);
if (ratio > .015 && ratio < .32) intermediate++;
}
check(samples > 1000 && intermediate < samples / 200,
"Opaque noon edge stays crisp, no foliage smoothing: " + intermediate + "/" + samples);
System.out.println("SHADOWS128_CRISP intermediate=" + intermediate + "/" + samples);
}
private record Opacity(int samples, int dark, int lit, double mean, double maximum) { }
private static Opacity opacity(Frame off, Frame on) {
sameSize(off,on);
int samples=0,dark=0,lit=0;double sum=0,maximum=0;
for(int y=off.height/10;y<off.height*9/10;y+=2) for(int x=off.width/10;x<off.width*9/10;x+=2) {
int i=y*off.width+x,a=off.pixels[i],b=on.pixels[i];
int r=a>>16&255,g=a>>8&255,blue=a&255;
if(Math.min(r,Math.min(g,blue))<24 || Math.max(r,Math.max(g,blue))-Math.min(r,Math.min(g,blue))>64)continue;
double ratio=(brightness(a)-brightness(b))/(double)brightness(a);
samples++;sum+=ratio;maximum=Math.max(maximum,ratio);
if(ratio>.02)dark++;else if(Math.abs(ratio)<.01)lit++;
}
check(samples>1000,"White concrete (including blue moonlight) available for shadow opacity");
var result=new Opacity(samples,dark,lit,sum/samples,maximum);
System.out.println("SHADOWS128_OPACITY " + result);
return result;
}
private static java.util.stream.Stream<net.minecraft.client.gui.components.events.GuiEventListener> widgets(
net.minecraft.client.gui.components.events.ContainerEventHandler root) {
return root.children().stream().flatMap(widget -> widget instanceof net.minecraft.client.gui.components.events.ContainerEventHandler child
? java.util.stream.Stream.concat(java.util.stream.Stream.of(widget), widgets(child)) : java.util.stream.Stream.of(widget));
}
private static void checkPreferences() {
VanillaLight.setEnabled(true);
VanillaLight.setEdgeHighlights(false);
VanillaLight.setSaturation(100);
for (int[] pair : new int[][]{{Integer.MIN_VALUE, 8}, {11, 8}, {12, 16}, {16, 16}, {23, 16}, {24, 32}, {Integer.MAX_VALUE, 32}}) {
VanillaLight.setShadowPixels(pair[0]);
forgetPreferences();
check(VanillaLight.shadowPixels() == pair[1], "Persisted pixel density rounds/clamps " + pair[0]);
}
for (int[] pair : new int[][]{{Integer.MIN_VALUE, 16}, {23, 16}, {24, 32}, {32, 32}, {47, 32}, {48, 64}, {95,64}, {96,128}, {191,128}, {192,256}, {256,256}, {Integer.MAX_VALUE, 256}}) {
VanillaLight.setShadowDistance(pair[0]);
forgetPreferences();
check(VanillaLight.shadowDistance() == pair[1], "Persisted shadow distance rounds/clamps " + pair[0]);
}
for (boolean enabled : new boolean[]{true, false}) {
VanillaLight.setPixelShadows(enabled);
forgetPreferences();
check(VanillaLight.pixelShadows() == enabled && VanillaLight.enabled(), "Saved shadow toggle is independent of main shader");
}
VanillaLight.setShadowPixels(16);
VanillaLight.setShadowDistance(32);
}
static void forgetPreferences() {
try {
var field = VanillaLight.class.getDeclaredField("preferences");
field.setAccessible(true);
field.set(null, null);
} catch (ReflectiveOperationException e) {
throw new AssertionError("Cannot reread actual shader preferences", e);
}
}
static void camera(ClientGameTestContext c, TestServerContext server, double x, double y, double z, int fov) {
server.runOnServer(s -> {
var p = s.getPlayerList().getPlayers().getFirst();
command(s, "tp @a " + x + " " + y + " " + z + " 0 90");
p.setDeltaMovement(Vec3.ZERO);
});
c.waitFor(m -> m.player.position().distanceToSqr(new Vec3(x, y, z)) < .0001
&& Math.abs(m.player.getXRot() - 90) < .001, 200);
c.runOnClient(m -> {
m.options.fov().set(fov);
m.player.setYRot(0);
m.player.yRotO = 0;
m.player.setXRot(90);
m.player.xRotO = 90;
m.player.setDeltaMovement(Vec3.ZERO);
});
c.waitTicks(15);
}
static void look(ClientGameTestContext c, TestServerContext server, float yaw, float pitch) {
server.runOnServer(s -> {
var p = s.getPlayerList().getPlayers().getFirst();
command(s, "tp @a " + p.getX() + " " + p.getY() + " " + p.getZ() + " " + yaw + " " + pitch);
});
c.waitFor(m -> Math.abs(m.player.getXRot() - pitch) < .00001
&& Math.abs(m.player.getYRot() - yaw) < .00001, 200);
c.waitTicks(15);
}
private static void pillar(MinecraftServer s, boolean present) {
for (int x = 0; x < 2; x++) for (int z = 0; z < 2; z++) for (int y = FLOOR + 1; y <= FLOOR + 8; y++) {
s.overworld().setBlockAndUpdate(new BlockPos(x, y, z),
(present ? Blocks.CONCRETE.red() : Blocks.AIR).defaultBlockState());
}
}
static void command(MinecraftServer s, String command) {
try {
// Dispatcher exceptions fail the fixture instead of leaving an ignored command error in chat.
s.getCommands().getDispatcher().execute(command, s.createCommandSourceStack());
} catch (com.mojang.brigadier.exceptions.CommandSyntaxException e) {
throw new AssertionError("Fixture command failed: " + command, e);
}
}
static void shadows(ClientGameTestContext c, boolean enabled) {
c.runOnClient(m -> VanillaLight.setPixelShadows(enabled));
if (enabled) activeFrames(c);
else {
c.waitTicks(6);
int frames = c.computeOnClient(m -> {
check(!PixelShadows.applied() && PixelShadows.shadowMap() == null, "OFF releases the GPU shadow target");
return PixelShadows.renderedFrames();
});
c.waitTicks(3);
c.runOnClient(m -> check(PixelShadows.renderedFrames() == frames, "OFF submits no shadow frames"));
}
}
static void activeFrames(ClientGameTestContext c) {
int before = c.computeOnClient(m -> PixelShadows.renderedFrames());
c.waitFor(m -> PixelShadows.applied() && PixelShadows.ready() && PixelShadows.renderedFrames() > before + 12, 200);
// Allow the bounded terrain cache and native light/chunk uploads to settle before reading pixels.
c.waitTicks(30);
}
static Frame capture(ClientGameTestContext c, String name) {
if (name != null) c.takeScreenshot("sanctuary-beta129-shadows-" + name);
var result = new CompletableFuture<Frame>();
c.runOnClient(m -> Screenshot.takeScreenshot(m.gameRenderer.mainRenderTarget(), image -> {
try (image) {
result.complete(new Frame(image.getWidth(), image.getHeight(), image.getPixels()));
} catch (Throwable e) {
result.completeExceptionally(e);
}
}));
c.waitFor(m -> result.isDone(), 200);
return result.join();
}
record Frame(int width, int height, int[] pixels) { }
private record Difference(int sampled, int ground, int dark, int stable, int brighter) { }
private static Difference compare(Frame before, Frame after) {
sameSize(before, after);
int sampled = 0, ground = 0, dark = 0, stable = 0, brighter = 0;
// Skip borders and sample every other pixel; no PNG decoding, image libraries or golden screenshot dependency.
for (int y = before.height / 10; y < before.height * 9 / 10; y += 2) {
for (int x = before.width / 10; x < before.width * 9 / 10; x += 2) {
sampled++;
int a = before.pixels[y * before.width + x], b = after.pixels[y * after.width + x];
if (!whiteGround(a)) continue;
ground++;
int delta = brightness(a) - brightness(b);
if (delta >= DARK_DELTA) dark++;
if (delta <= -DARK_DELTA) brighter++;
if (channelDifference(a, b) <= 3) stable++;
}
}
return new Difference(sampled, ground, dark, stable, brighter);
}
private static void localShadow(Difference d, String label, double minimumStable) {
System.out.println("PIXEL_SHADOWS122_PIXELS " + label + ": " + d);
check(d.ground > d.sampled * .50, label + ": a majority of samples must be actual white receiving ground: " + d);
check(d.dark >= Math.max(24, d.ground / 1000), label + ": measurable cast-shadow darkening: " + d);
check(d.stable > d.ground * minimumStable, label + ": unshadowed ground stays unchanged; reject a global tint: " + d);
check(d.brighter < Math.max(12, d.ground / 100), label + ": shadows must not brighten ground: " + d);
}
static void unchanged(Frame before, Frame after, String label) {
sameSize(before, after);
int sampled = 0, changed = 0;
for (int y = before.height / 10; y < before.height * 9 / 10; y += 2) {
for (int x = before.width / 10; x < before.width * 9 / 10; x += 2) {
sampled++;
int i = y * before.width + x;
if (channelDifference(before.pixels[i], after.pixels[i]) > 3) changed++;
}
}
check(sampled > 0 && changed <= sampled / 100, label + ": changed=" + changed + "/" + sampled);
}
private static void restoredGround(Frame off, Frame on, Frame removed) {
sameSize(off, on);
sameSize(off, removed);
int shadow = 0, restored = 0, lit = 0, stable = 0;
for (int y = off.height / 10; y < off.height * 9 / 10; y += 2) {
for (int x = off.width / 10; x < off.width * 9 / 10; x += 2) {
int i = y * off.width + x;
if (!whiteGround(off.pixels[i])) continue;
if (brightness(off.pixels[i]) - brightness(on.pixels[i]) >= DARK_DELTA) {
shadow++;
if (brightness(removed.pixels[i]) - brightness(on.pixels[i]) >= DARK_DELTA
&& channelDifference(off.pixels[i], removed.pixels[i]) <= 4) restored++;
} else if (channelDifference(off.pixels[i], on.pixels[i]) <= 3) {
lit++;
if (channelDifference(on.pixels[i], removed.pixels[i]) <= 3) stable++;
}
}
}
check(shadow >= 24 && restored > shadow * .85,
"Removing offscreen caster relights its former shadow: " + restored + "/" + shadow);
check(lit >= 24 && stable > lit * .98,
"Removing caster preserves already sunlit ground: " + stable + "/" + lit);
System.out.println("PIXEL_SHADOWS122_EDIT restored=" + restored + "/" + shadow + " litStable=" + stable + "/" + lit);
}
private static boolean whiteGround(int color) {
int r = color >> 16 & 255, g = color >> 8 & 255, b = color & 255;
return Math.min(r, Math.min(g, b)) > 100 && Math.max(r, Math.max(g, b)) - Math.min(r, Math.min(g, b)) < 24;
}
static int brightness(int color) {
return ((color >> 16 & 255) + (color >> 8 & 255) + (color & 255)) / 3;
}
static int channelDifference(int a, int b) {
return Math.max(Math.abs((a >> 16 & 255) - (b >> 16 & 255)),
Math.max(Math.abs((a >> 8 & 255) - (b >> 8 & 255)), Math.abs((a & 255) - (b & 255))));
}
private static void sameSize(Frame a, Frame b) {
check(a.width == b.width && a.height == b.height && a.pixels.length == b.pixels.length,
"GPU comparison requires the same framebuffer dimensions");
}
private record Options(int distance, int simulationDistance, int fov, boolean bob,
boolean pause, CameraType camera, String language) {
static Options capture(Minecraft m) {
return new Options(m.options.renderDistance().get(), m.options.simulationDistance().get(), m.options.fov().get(),
m.options.bobView().get(), m.options.pauseOnLostFocus,
m.options.getCameraType(), m.getLanguageManager().getSelected());
}
void restore(Minecraft m) {
m.options.renderDistance().set(distance);
m.options.simulationDistance().set(simulationDistance);
m.options.fov().set(fov);
m.options.bobView().set(bob);
m.options.pauseOnLostFocus = pause;
m.options.setCameraType(camera);
m.getLanguageManager().setSelected(language);
m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
}
}
private static KeyEvent enter() { return new KeyEvent(InputConstants.KEY_RETURN, 0, 0); }
static void check(boolean ok, String message) { if (!ok) throw new AssertionError(message); }
}
@@ -0,0 +1,115 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import fr.koka.sanctuary.realtime.RealtimeService;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderEvents;
final class RealtimeShadows129ClientChecks {
private static void projectionContinuity() {
// Far coordinates amplify the old per-frame rounding failure. Moving the sun must not
// add a periodic translation to the shadow raster while the camera remains stationary.
var grid=new ShadowProjection();
float last=0; int jumps=0; float greatest=0;
for(int i=0;i<2000;i++) {
var matrix=grid.matrix(PixelShadows.sunDirection(.7f+i*.000001f),32,2048,100000,64,-100000,false);
if(i>0){float step=Math.abs(matrix.m31()-last);greatest=Math.max(greatest,step);if(step>1e-6f)jumps++;}
last=matrix.m31();
}
check(jumps==0,"Slow light rotation has no cyclical raster translation far from spawn: "+greatest);
// Recentring transfers the phase modulo integral texels, preserving the sampled grid.
var sun=PixelShadows.sunDirection(.71f);
var before=grid.matrix(sun,32,2048,100000,64,-100000,false);
var after=grid.matrix(sun,32,2048,100016,64,-100000,false);
var a=before.transform(new org.joml.Vector4f(4,1,2,1));
var b=after.transform(new org.joml.Vector4f(-12,1,2,1));
for(float value:new float[]{(b.x-a.x)*1024,(b.y-a.y)*1024})
check(Math.abs(value-Math.rint(value))<.001,"Camera recentering moves only whole shadow texels");
}
static void run(ClientGameTestContext c, TestServerContext server) {
projectionContinuity();
var enabled=new AtomicBoolean(false);
var trace=Collections.synchronizedList(new ArrayList<double[]>());
LevelRenderEvents.END_EXTRACTION.register(context -> {
if(enabled.get() && trace.size()<4096)trace.add(new double[]{System.nanoTime()/1e9,context.levelState().skyRenderState.sunAngle});
});
var original=new AtomicReference<Clock>();
try {
server.runOnServer(s -> {
var session=RealtimeService.session(s);
check(session!=null,"Isolated world has the actual real-time service");
try {
var field=session.getClass().getDeclaredField("clock");field.setAccessible(true);
original.set((Clock)field.get(session));
var morning=session.sample().day().noon().minusSeconds(3*3600);
field.set(session,Clock.offset(Clock.systemUTC(),Duration.between(Instant.now(),morning)));
check(RealtimeService.setMode(s,true),"Real-time mode enabled through actual server service");
} catch(ReflectiveOperationException e) {throw new AssertionError(e);}
catch(Exception e) {throw new AssertionError(e);}
});
c.waitFor(m -> PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle).y>.2,200);
c.waitTicks(40);
double shadowX=c.computeOnClient(m -> {
var sun=PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);
return 1-7*sun.x/sun.y;
});
camera(c,server,shadowX,69,1,30);
shadows(c,false);
var off=capture(c,null);
shadows(c,true);
enabled.set(true);
var frames=new ArrayList<Frame>();
for(int i=0;i<24;i++) {c.waitTicks(6);frames.add(capture(c,i==0?"realtime-start":i==23?"realtime-end":null));}
c.runOnClient(m -> enabled.set(false));
double travel=Math.IEEEremainder(trace.getLast()[1]-trace.getFirst()[1],Math.PI*2);
int reverse=0;double largest=0;
for(int i=1;i<trace.size();i++) {
double delta=Math.IEEEremainder(trace.get(i)[1]-trace.get(i-1)[1],Math.PI*2)*Math.signum(travel);
if(delta<-.0000001){reverse++;largest=Math.max(largest,-delta);}
}
int samples=0,changed=0,rebound=0;
for(int y=off.height()/10;y<off.height()*9/10;y+=2) for(int x=off.width()/10;x<off.width()*9/10;x+=2) {
int index=y*off.width()+x,a=off.pixels()[index];
int r=a>>16&255,g=a>>8&255,b=a&255;
if(Math.min(r,Math.min(g,b))<100 || Math.max(r,Math.max(g,b))-Math.min(r,Math.min(g,b))>24)continue;
samples++;int flips=0;
boolean last=brightness(a)-brightness(frames.getFirst().pixels()[index])>=8;
for(var frame:frames) {
boolean dark=brightness(a)-brightness(frame.pixels()[index])>=8;
if(last!=dark)flips++;
last=dark;
}
if(flips>0)changed++;
if(flips>1)rebound++;
}
check(trace.size()>30 && Math.abs(travel)>.00001,"Real-time shadows actually remain dynamic");
check(rebound<Math.max(8,samples/1000),"Real-time opaque contours must not repeatedly rebound: "+rebound);
System.out.println("REALTIME129_PASS frames="+trace.size()+" rawTravel="+travel+" reversals="+reverse+" largestReverse="+largest
+" pixels="+samples+" changed="+changed+" rebound="+rebound);
try {
var out=new StringBuilder("seconds,raw_angle\n");
for(var sample:trace)out.append(sample[0]).append(',').append(sample[1]).append('\n');
java.nio.file.Files.writeString(net.fabricmc.loader.api.FabricLoader.getInstance().getGameDir().resolve("shadow129-clock.csv"),out);
}catch(java.io.IOException e){throw new AssertionError(e);}
} finally {
enabled.set(false);
server.runOnServer(s -> {
try {
RealtimeService.setMode(s,false);
if(original.get()!=null){var session=RealtimeService.session(s);var field=session.getClass().getDeclaredField("clock");field.setAccessible(true);field.set(session,original.get());}
command(s,"time set 3000");
}catch(Exception e){throw new AssertionError(e);}
});
}
}
}
@@ -0,0 +1,72 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.inventory.CreativeModeInventoryScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.multiplayer.SessionSearchTrees;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.item.*;
import net.minecraft.world.level.GameType;
public final class Tooltip093ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{
if(m.gui.screen() instanceof IntroScreen s)s.onClose();
if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen s){Starter039TestSupport.selectFirst(s);
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}
});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Tooltip093 "+UUID.randomUUID());s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
world.getServer().runOnServer(s->s.getPlayerList().getPlayers().getFirst().setGameMode(GameType.CREATIVE));
var eggs=c.computeOnClient(m->BuiltInRegistries.ITEM.stream().filter(SpawnEggItem.class::isInstance).map(ItemStack::new).filter(e->CompanionTooltips.type(e)!=null).toList());
check(eggs.size()==88,"All 88 familiar eggs covered");Tooltip093Probe.armed=true;
for(String locale:List.of("fr_fr","en_us")){
var reload=c.computeOnClient(m->{m.getLanguageManager().setSelected(locale);m.options.languageCode=locale;return m.reloadResourcePacks();});c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();
for(boolean details:new boolean[]{false,true}){
c.runOnClient(m->{ProgressionClient.operator=false;CompanionClient.studied=details;});
var context=c.computeOnClient(m->Item.TooltipContext.of(m.level));
var player=c.computeOnClient(m->m.player);
var async=CompletableFuture.supplyAsync(()->eggs.stream().map(e->e.getTooltipLines(context,player,TooltipFlag.NORMAL)).toList());
c.waitFor(m->async.isDone(),300);var workerLines=async.join();
c.runOnClient(m->{for(int i=0;i<eggs.size();i++){
var visible=eggs.get(i).getTooltipLines(context,player,TooltipFlag.NORMAL);
check(text(visible).equals(text(workerLines.get(i))),"Search retains the entire displayed description: "+locale+" / "+eggs.get(i));
check(visible.size()>2,"Familiar description is present");
check(visible.stream().noneMatch(t->t.getString().startsWith("sanctuary.")),"Descriptions remain translated");
}});
// Exactly the native asynchronous path from the supplied crash, without joining on the render thread.
var search=c.computeOnClient(m->{var trees=new SessionSearchTrees();trees.updateCreativeTooltips(m.level.registryAccess(),eggs);return trees;});
var indexed=CompletableFuture.supplyAsync(search::creativeNameSearch);c.waitFor(m->indexed.isDone(),300);var tree=indexed.join();
String query=c.computeOnClient(m->eggs.getFirst().getHoverName().getString().toLowerCase(Locale.ROOT));
check(!tree.search(query).isEmpty(),"Native creative search resolves the indexed egg");
c.runOnClient(m->search.rebuildAfterLanguageChange());var again=CompletableFuture.supplyAsync(search::creativeNameSearch);c.waitFor(m->again.isDone(),300);check(!again.join().search(query).isEmpty(),"Language rebuild retains indexed results");
System.out.println("TOOLTIP093_INDEX_PASS locale="+locale+" details="+details);
}
c.runOnClient(m->m.gui.setScreen(new CreativeModeInventoryScreen(m.player,m.level.enabledFeatures(),false)));c.waitTicks(8);
// Native creative inventory selects the search tab when its search key is pressed.
c.getInput().pressKey(o->o.keyChat);c.waitTicks(4);
c.getInput().typeChars("w");c.waitTicks(5);c.getInput().typeChars("olf");c.waitTicks(5);
c.runOnClient(m->check(m.gui.screen() instanceof CreativeModeInventoryScreen,"Typing in creative inventory remains usable"));
c.takeScreenshot("tooltip093-creative-search-"+locale);c.runOnClient(m->m.gui.setScreen(null));
}
System.out.println("TOOLTIP093_PASS: worker tooltips and native search/rebuild, all 88 eggs, cryptic/detailed FR/EN, resource reload and creative typing");
}finally{Tooltip093Probe.armed=false;intro.set(false);}
}
private static String text(List<Component> lines){return lines.stream().map(Component::getString).reduce("",String::concat).replaceAll("\\s+","");}
private static void check(boolean v,String message){if(!v)throw new AssertionError(message);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
}
@@ -0,0 +1,15 @@
package fr.koka.sanctuary.gametest;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.core.particles.ParticleOptions;
import net.minecraft.world.phys.Vec3;
public final class Fourneau094Probe {
public record Emission(ParticleOptions type,Vec3 point){}
public static boolean armed;
public static final List<Emission> emissions=new ArrayList<>();
public static void record(ParticleOptions type,double x,double y,double z){
if(armed)emissions.add(new Emission(type,new Vec3(x,y,z)));
}
}
@@ -0,0 +1,11 @@
package fr.koka.sanctuary.gametest;
/** Test-only guard: even a warm font cache must never conceal off-thread glyph access. */
public final class Tooltip093Probe {
public static volatile boolean armed;
private Tooltip093Probe() {}
public static void fontAccess(){
if(armed&&!net.minecraft.client.Minecraft.getInstance().isSameThread())
throw new AssertionError("TOOLTIP093_OFF_THREAD_FONT: tooltip indexing accessed the rendering font");
}
}
@@ -0,0 +1,17 @@
package fr.koka.sanctuary.gametest.mixin;
import fr.koka.sanctuary.gametest.Fourneau094Probe;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.particles.ParticleOptions;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(ClientLevel.class)
public abstract class Fourneau094ParticleProbeMixin {
@Inject(method="addParticle(Lnet/minecraft/core/particles/ParticleOptions;DDDDDD)V",at=@At("HEAD"))
private void sanctuary$particle(ParticleOptions type,double x,double y,double z,double vx,double vy,double vz,CallbackInfo ci){
Fourneau094Probe.record(type,x,y,z);
}
}
@@ -0,0 +1,13 @@
package fr.koka.sanctuary.gametest.mixin;
import fr.koka.sanctuary.gametest.Tooltip093Probe;
import net.minecraft.client.gui.Font;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(Font.class)
abstract class Tooltip093FontProbeMixin {
@Inject(method={"getSplitter","width"},at=@At("HEAD"))
private void verifyThread(CallbackInfoReturnable<?> ci){Tooltip093Probe.fontAccess();}
}
@@ -10,6 +10,8 @@
"defaultRequire": 1
},
"client": [
"Fourneau094ParticleProbeMixin",
"Tooltip093FontProbeMixin",
"ClientVsync046Mixin",
"Inventory012RenderProbeMixin",
"Atlas026ScreenProbeMixin",
@@ -43,6 +43,8 @@ public final class SanctuaryMod implements ModInitializer {
@Override
public void onInitialize() {
fr.koka.sanctuary.building.ColoredBricks.register();
fr.koka.sanctuary.gems.Gems.register();
fr.koka.sanctuary.sky.VortexClouds.register();
fr.koka.sanctuary.loading.PreloadProgress.register();
fr.koka.sanctuary.progression.SanctuaryGameplay.register();
@@ -70,6 +72,7 @@ public final class SanctuaryMod implements ModInitializer {
fr.koka.sanctuary.realtime.RealtimeService.register();
fr.koka.sanctuary.weather.WeatherService.register();
fr.koka.sanctuary.plans.PlanService.register();
fr.koka.sanctuary.plans.Statuary.register();
fr.koka.sanctuary.food.FoodSync.register();
fr.koka.sanctuary.food.FoodKnowledgeService.register();
fr.koka.sanctuary.grave.GraveService.register();
@@ -0,0 +1,58 @@
package fr.koka.sanctuary.building;
import fr.koka.sanctuary.SanctuaryMod;
import java.util.*;
import net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.*;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.BlockBehaviour;
/** Native building blocks; no world-generation, ticking entities or custom inventory. */
public final class ColoredBricks {
public record Family(DyeColor color,Block bricks,Block slab,Block stairs,Block wall,Block clay,Item clayBall,Item brick){}
private static final List<Family> FAMILIES=new ArrayList<>();
// The order used by the native 26.3 Colored Blocks tab, rather than dye numeric IDs.
private static final List<DyeColor> CREATIVE_COLORS=List.of(
DyeColor.WHITE,DyeColor.LIGHT_GRAY,DyeColor.GRAY,DyeColor.BLACK,
DyeColor.BROWN,DyeColor.RED,DyeColor.ORANGE,DyeColor.YELLOW,
DyeColor.LIME,DyeColor.GREEN,DyeColor.CYAN,DyeColor.LIGHT_BLUE,
DyeColor.BLUE,DyeColor.PURPLE,DyeColor.MAGENTA,DyeColor.PINK);
public static List<Family> families(){return List.copyOf(FAMILIES);}
public static void register(){
for(var color:DyeColor.values()){
String name=color.getName();
var bricks=block(name+"_bricks",new Block(properties(name+"_bricks",Blocks.BRICKS,color)));
var slab=block(name+"_brick_slab",new SlabBlock(properties(name+"_brick_slab",Blocks.BRICK_SLAB,color)));
var stairs=block(name+"_brick_stairs",new Stairs(bricks,properties(name+"_brick_stairs",Blocks.BRICK_STAIRS,color)));
var wall=block(name+"_brick_wall",new WallBlock(properties(name+"_brick_wall",Blocks.BRICK_WALL,color)));
var clay=block(name+"_clay",new Block(properties(name+"_clay",Blocks.CLAY,color)));
var clayBall=item(name+"_clay_ball");var brick=item(name+"_brick");
FAMILIES.add(new Family(color,bricks,slab,stairs,wall,clay,clayBall,brick));
}
var display=FAMILIES.stream().sorted(Comparator.comparingInt(f->CREATIVE_COLORS.indexOf(f.color()))).toList();
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.COLORED_BLOCKS).register(out->{
for(var f:display)out.accept(f.bricks);
for(var f:display)out.accept(f.stairs);
for(var f:display)out.accept(f.slab);
for(var f:display)out.accept(f.wall);
for(var f:display)out.accept(f.clay);
});
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.INGREDIENTS).register(out->{
for(var f:display)out.accept(f.clayBall);
for(var f:display)out.accept(f.brick);
});
}
private static BlockBehaviour.Properties properties(String id,Block source,DyeColor color){
return BlockBehaviour.Properties.ofFullCopy(source).mapColor(color.getMapColor()).setId(ResourceKey.create(Registries.BLOCK,SanctuaryMod.id(id)));
}
private static Block block(String name,Block block){
var id=SanctuaryMod.id(name);Registry.register(BuiltInRegistries.BLOCK,id,block);
Registry.register(BuiltInRegistries.ITEM,id,new BlockItem(block,new Item.Properties().setId(ResourceKey.create(Registries.ITEM,id)).useBlockDescriptionPrefix()));return block;
}
private static Item item(String name){var id=SanctuaryMod.id(name);return Registry.register(BuiltInRegistries.ITEM,id,new Item(new Item.Properties().setId(ResourceKey.create(Registries.ITEM,id))));}
private static final class Stairs extends StairBlock {Stairs(Block base,BlockBehaviour.Properties properties){super(base.defaultBlockState(),properties);}}
private ColoredBricks(){}
}
@@ -0,0 +1,133 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.plans.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import com.mojang.blaze3d.Blaze3D;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** File selection is local; the two ordinary inventory slots and every crafted item are server-owned. */
public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorkshopMenu> {
private static final Identifier FRAME=fr.koka.sanctuary.SanctuaryMod.id("container/clay_workshop/frame");
private static final Identifier SLOT=Identifier.withDefaultNamespace("container/slot");
private static final Identifier SELECTION=fr.koka.sanctuary.SanctuaryMod.id("hud/hotbar_selection");
private static final ExecutorService WORKER=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"Sanctuary clay models");t.setDaemon(true);return t;});
private boolean imported;
private List<net.minecraft.world.entity.EntityType<?>> animals=List.of();
private List<Path> files=List.of();private int page,generation,ticks;private boolean busy,pending;
private VoxelSculpture source,previewSource;private ItemStack preview=ItemStack.EMPTY,lastMaterial=ItemStack.EMPTY;
private SculpturePalette palette;private Object sampledResources;
private Component status=text("choose_model");private Button rotate;
public ClayWorkshopScreen(ClayWorkshopMenu menu,Inventory inv,Component title){super(menu,inv,title,310,202);}
public static Component text(String key,Object...args){return Component.translatable("sanctuary.statuary."+key,args);}
public static void register(){
net.minecraft.client.gui.screens.MenuScreens.register(Statuary.MENU,ClayWorkshopScreen::new);StatuaryRenderer.register();
net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback.EVENT.register((stack,context,flags,lines)->{
if(!stack.is(Statuary.ITEM))return;var sculpture=StatuaryEntity.sculpture(stack);if(sculpture!=null){
if(!stack.getHoverName().getString().equals(Statuary.BLOCK.getName().getString()))lines.add(Statuary.BLOCK.getName());
lines.add(text("model_name",sculpture.name()));if(flags.isAdvanced())lines.add(Component.literal(sculpture.modelId()));}
});
}
@Override protected void init(){
super.init();
try{files=CreationLibrary.files(CreationLibrary.Kind.MODEL);}catch(Exception e){status=ModelImportErrors.message(e);}
animals=menu.view.statues().stream().map(net.minecraft.resources.Identifier::tryParse).filter(Objects::nonNull)
.<net.minecraft.world.entity.EntityType<?>>map(id->net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getValue(id)).filter(Objects::nonNull)
.sorted(Comparator.comparing(t->t.getDescription().getString())).toList();
int count=imported?files.size():animals.size();page=Math.min(page,Math.max(0,(count-1)/4));
addRenderableWidget(Button.builder(text("animals"),b->{imported=false;page=0;rebuildWidgets();}).bounds(leftPos+10,topPos+19,62,16).build()).active=imported;
addRenderableWidget(Button.builder(text("imports"),b->{imported=true;page=0;rebuildWidgets();}).bounds(leftPos+74,topPos+19,64,16).build()).active=!imported;
for(int i=0;i<4;i++){int at=page*4+i;if(at>=count)break;
var label=imported?Component.literal(files.get(at).getFileName().toString()):animals.get(at).getDescription();
var b=addRenderableWidget(Button.builder(label,p->{if(imported)load(files.get(at));else load(animals.get(at));}).bounds(leftPos+10,topPos+36+i*17,128,17).build());b.setTooltip(Tooltip.create(label));}
addRenderableWidget(Button.builder(Component.literal(""),b->{page--;rebuildWidgets();}).bounds(leftPos+10,topPos+106,22,18).build()).active=page>0;
addRenderableWidget(Button.builder(text("folder"),b->folder()).bounds(leftPos+35,topPos+106,78,18).build());
addRenderableWidget(Button.builder(Component.literal(""),b->{page++;rebuildWidgets();}).bounds(leftPos+116,topPos+106,22,18).build()).active=(page+1)*4<count;
addRenderableWidget(Button.builder(text("refresh"),b->rebuildWidgets()).bounds(leftPos+10,topPos+127,128,18).build());
rotate=addRenderableWidget(Button.builder(text("rotate"),b->{if(source!=null){source=source.rotate();pending=true;}}).bounds(leftPos+148,topPos+127,152,18).build());rotate.active=source!=null&&!busy;
rotate.setTooltip(Tooltip.create(text("placement")));
}
private void folder(){try{Files.createDirectories(CreationLibrary.directory());Blaze3D.openPath(CreationLibrary.directory());}catch(Exception e){status=ModelImportErrors.message(e);}}
public void load(Path file){
if(busy)return;busy=true;status=text("working");int token=++generation;
CompletableFuture.supplyAsync(()->{try{var asset=GlbModel.load(file);return VoxelSculpture.of(asset.id(),asset.name(),StatueVoxelizer.miniature(asset.faces()));}catch(Exception e){throw new CompletionException(e);}},WORKER)
.whenComplete((sculpture,error)->net.minecraft.client.Minecraft.getInstance().execute(()->{
if(token!=generation||minecraft.gui.screen()!=this||minecraft.player==null||minecraft.player.containerMenu!=menu)return;
busy=false;if(error!=null){status=ModelImportErrors.message(error.getCause());return;}
source=sculpture;pending=true;status=text("ready",sculpture.name());
}));
}
public void load(net.minecraft.world.entity.EntityType<?> type){
var id=net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getKey(type).toString();
if(busy||!menu.view.statues().contains(id))return;
try{
var entity=type.create(minecraft.level,net.minecraft.world.entity.EntitySpawnReason.MOB_SUMMONED);
if(entity==null)throw new IllegalArgumentException("unsupported_model");
entity.setId(Integer.MIN_VALUE+125);entity.setPos(minecraft.player.position());
// Native renderers and textures are read on the client thread; only voxelization runs in the worker.
var faces=StatueModels.capture(entity);String name=type.getDescription().getString();
busy=true;status=text("working");int token=++generation;
CompletableFuture.supplyAsync(()->VoxelSculpture.of(StatueDiscoveries.modelId(id),name,StatueVoxelizer.miniature(faces)),WORKER)
.whenComplete((sculpture,error)->minecraft.execute(()->{
if(token!=generation||minecraft.gui.screen()!=this||minecraft.player==null||minecraft.player.containerMenu!=menu)return;
busy=false;if(error!=null){fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Unable to sculpt creature {}",id,error);status=text("animal_error");return;}
source=sculpture;pending=true;status=text("ready",name);
}));
}catch(Exception e){fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Unable to capture creature {}",id,e);status=text("animal_error");}
}
@Override protected void containerTick(){
super.containerTick();rotate.active=source!=null&&!busy;
var material=menu.getSlot(0).getItem();var resources=minecraft.getModelManager().getBlockStateModelSet();
boolean changedMaterial=!ItemStack.isSameItemSameComponents(lastMaterial,material)||sampledResources!=resources;
if(previewSource!=source||changedMaterial){
previewSource=source;lastMaterial=material.copy();sampledResources=resources;
if(changedMaterial)palette=SculptureMaterialSampler.sample(material);
var colored=source==null?null:palette==null?source:ClayWorkshopMenu.appearance(source,material.getItem(),palette);
preview=colored==null?ItemStack.EMPTY:StatuaryEntity.item(colored);pending=true;
if(source!=null&&!busy)status=palette==null?text("choose_material"):material.is(Items.CLAY)?text("clay_ready"):text("material_ready",material.getHoverName());
}
// Retry a selection that raced the input packet or server rate limit. The result is the
// acknowledgement; never predict consumption or manufacture an item on the client.
boolean needsResult=source!=null&&palette!=null&&!ItemStack.matches(preview,menu.getSlot(1).getItem());
if(++ticks>=5&&(pending||needsResult)){
ticks=0;pending=false;
if(source!=null&&palette!=null)ClientPlayNetworking.send(new ClayWorkshopMenu.Select(menu.containerId,menu.view.token(),source,material.getItem(),palette));
}
}
@Override public void onFilesDrop(List<Path> files){if(!files.isEmpty())load(files.getFirst());}
@Override public void removed(){generation++;super.removed();}
int panelHeight(){return imageHeight;}
@Override public void extractBackground(GuiGraphicsExtractor g,int mx,int my,float partial){
g.blitSprite(RenderPipelines.GUI_TEXTURED,FRAME,leftPos,topPos,imageWidth,panelHeight());
g.fill(leftPos+148,topPos+23,leftPos+300,topPos+92,0xFF8B8B8B);
g.fill(leftPos+149,topPos+24,leftPos+299,topPos+91,0xFF373737);
for(var slot:menu.slots)if(slot.x>=0&&slot.y>=0)g.blitSprite(RenderPipelines.GUI_TEXTURED,SLOT,leftPos+slot.x-1,topPos+slot.y-1,18,18);
if(minecraft.player!=null)g.blitSprite(RenderPipelines.GUI_TEXTURED,SELECTION,
leftPos+ClayWorkshopMenu.HOTBAR_X-3+minecraft.player.getInventory().getSelectedSlot()*18,topPos+ClayWorkshopMenu.HOTBAR_Y-3,22,21);
if(!preview.isEmpty()){
g.pose().pushMatrix();g.pose().translate(leftPos+194,topPos+27);g.pose().scale(3.75f,3.75f);g.item(preview,0,0);g.pose().popMatrix();
}
g.text(font,Component.literal(""),leftPos+254,topPos+111,0xFF404040,false);
var detail=font.split(status,290);for(int i=0;i<Math.min(2,detail.size());i++)g.text(font,detail.get(i),leftPos+10,topPos+150+i*font.lineHeight,0xFF404040,false);
if(detail.size()>2&&mx>=leftPos+10&&mx<leftPos+300&&my>=topPos+148&&my<topPos+174)g.setTooltipForNextFrame(font,status,mx,my);
if(imported?files.isEmpty():animals.isEmpty()){var lines=font.split(text(imported?"empty":"no_animals"),128);for(int i=0;i<Math.min(6,lines.size());i++)g.text(font,lines.get(i),leftPos+10,topPos+38+i*font.lineHeight,0xFF404040,false);}
}
@Override protected void extractLabels(GuiGraphicsExtractor g,int mx,int my){
g.text(font,title,10,8,0xFF303030,false);
g.text(font,text("input"),230-font.width(text("input"))/2,95,0xFF303030,false);g.text(font,text("output"),imageWidth-10-font.width(text("output")),95,0xFF303030,false);
}
ItemStack preview(){return preview;}
public boolean busy(){return busy;}
public VoxelSculpture selected(){return source;}
}
@@ -62,8 +62,7 @@ public final class CompanionTooltips {
for(var technique:java.util.List.of(initiative,profile.signature())){
var text=FamiliarClient.name(technique).copy();
if(details)text.append("").append(FamiliarClient.help(technique));
var m=net.minecraft.client.Minecraft.getInstance();int width=Math.max(100,Math.min(280,m.getWindow().getGuiScaledWidth()-32));
for(var part:m.font.getSplitter().splitLines(text,width,net.minecraft.network.chat.Style.EMPTY))extra.add(Component.literal(part.getString()).withStyle(ChatFormatting.GRAY));
addWrapped(extra,text);
}
if(details)extra.add(CompanionClient.label("cooldown_detail",profile.signature().cooldown()/20).copy().withStyle(ChatFormatting.DARK_GRAY));
lines.addAll(Math.min(1,lines.size()),extra);return;
@@ -71,14 +70,24 @@ public final class CompanionTooltips {
for(var power:new String[]{"passive","active"}){
var description=Component.translatable("sanctuary.companion."+type.id()+"."+power+(details?"":"_name"));
var line=CompanionClient.label("tooltip_"+power,description);
if(details){
var m=net.minecraft.client.Minecraft.getInstance();int width=Math.max(100,Math.min(280,m.getWindow().getGuiScaledWidth()-32));
for(var part:m.font.getSplitter().splitLines(line,width,net.minecraft.network.chat.Style.EMPTY))
extra.add(Component.literal(part.getString()).withStyle(ChatFormatting.GRAY));
}else extra.add(line.copy().withStyle(ChatFormatting.GRAY));
if(details)addWrapped(extra,line);
else extra.add(line.copy().withStyle(ChatFormatting.GRAY));
}
if(details)extra.add(CompanionClient.label("cooldown_detail",CompanionClient.config.entry(type).cooldownSeconds()).copy().withStyle(ChatFormatting.DARK_GRAY));
lines.addAll(Math.min(1,lines.size()),extra);
});
}
private static void addWrapped(java.util.List<Component> lines,Component text){
var m=net.minecraft.client.Minecraft.getInstance();
// Creative search (and item viewers) build tooltips on workers. Font measurement
// can upload a new glyph to the GPU, so even measuring text must stay on the client thread.
// Never enqueue and wait here: the client may itself be waiting for the search index.
if(!m.isSameThread()){
lines.add(text.copy().withStyle(ChatFormatting.GRAY));
return;
}
int width=Math.max(100,Math.min(280,m.getWindow().getGuiScaledWidth()-32));
for(var part:m.font.getSplitter().splitLines(text,width,net.minecraft.network.chat.Style.EMPTY))
lines.add(Component.literal(part.getString()).withStyle(ChatFormatting.GRAY));
}
}
@@ -0,0 +1,45 @@
package fr.koka.sanctuary.client;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.AbstractFurnaceBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.Vec3;
/** One animation source per facade; texture coordinates are measured on the full 48px face. */
public final class FourneauFire {
public static Vec3 point(BlockPos origin,Direction facing,double pixelX,double pixelY){
double u=pixelX/16.0,v=3-pixelY/16.0,edge=0.02;
var local=switch(facing){
case NORTH -> new Vec3(3-u,v,-edge);
case SOUTH -> new Vec3(u,v,3+edge);
case EAST -> new Vec3(3+edge,v,3-u);
case WEST -> new Vec3(-edge,v,u);
default -> throw new IllegalArgumentException("Horizontal furnace facade required");
};
return local.add(origin.getX(),origin.getY(),origin.getZ());
}
public static boolean animate(BlockState state,Level level,BlockPos pos,RandomSource random){
var tile=FourneauModels.fireTile(pos);
if(tile==null)return false;
// Cancel all 27 native sources, even when cold; only the front middle cell emits.
if(!state.getValue(AbstractFurnaceBlock.LIT)||!tile.emitter())return true;
var origin=tile.origin(pos);
if(random.nextDouble()<0.1){
var sound=point(origin,tile.facing(),24,24);
level.playLocalSound(sound.x,sound.y,sound.z,SoundEvents.FURNACE_FIRE_CRACKLE,SoundSource.BLOCKS,1,1,false);
}
for(int row:new int[]{16,37}){
var p=point(origin,tile.facing(),12+random.nextDouble()*24,row+0.25+random.nextDouble()*0.5);
level.addParticle(ParticleTypes.SMOKE,p.x,p.y,p.z,0,0,0);
level.addParticle(ParticleTypes.FLAME,p.x,p.y,p.z,0,0,0);
}
return true;
}
private FourneauFire(){}
}
@@ -25,7 +25,15 @@ import net.minecraft.world.level.block.state.BlockState;
public final class FourneauModels {
private static volatile Map<Long,Map<BlockPos,Identifier>> chunks=Map.of();
private static final List<Identifier> MODELS=new ArrayList<>();
static {for(int y=0;y<3;y++)for(int z=0;z<3;z++)for(int x=0;x<3;x++)MODELS.add(caskId(x,y,z));for(var d:Direction.Plane.HORIZONTAL)for(int y=0;y<3;y++)for(int z=0;z<3;z++)for(int x=0;x<3;x++)MODELS.add(id(d,x,y,z));}
private static final Map<Identifier,FireTile> FIRE_TILES=new HashMap<>();
public record FireTile(Direction facing,int x,int y,int z) {
public BlockPos origin(BlockPos pos){return pos.offset(-x,-y,-z);}
public boolean emitter(){return y==1 && switch(facing){case NORTH -> x==1&&z==0;case SOUTH -> x==1&&z==2;case EAST -> x==2&&z==1;case WEST -> x==0&&z==1;default -> false;};}
}
public static FireTile fireTile(BlockPos pos){return FIRE_TILES.get(modelAt(pos));}
private static Identifier litId(Identifier id){return id.withPath(id.getPath()+"_lit");}
public static Identifier modelAt(BlockPos pos,BlockState state){var id=modelAt(pos);return id!=null&&state.is(Blocks.FURNACE)&&state.getValue(net.minecraft.world.level.block.AbstractFurnaceBlock.LIT)&&FIRE_TILES.containsKey(id)?litId(id):id;}
static {for(int y=0;y<3;y++)for(int z=0;z<3;z++)for(int x=0;x<3;x++)MODELS.add(caskId(x,y,z));for(var d:Direction.Plane.HORIZONTAL)for(int y=0;y<3;y++)for(int z=0;z<3;z++)for(int x=0;x<3;x++){var id=id(d,x,y,z);MODELS.add(id);MODELS.add(litId(id));FIRE_TILES.put(id,new FireTile(d,x,y,z));}}
private static Identifier id(Direction d,int x,int y,int z){return SanctuaryMod.id("block/fourneau/"+d.getName()+"_"+x+"_"+y+"_"+z);}
private static Identifier caskId(int x,int y,int z){return SanctuaryMod.id("block/fut/"+x+"_"+y+"_"+z);}
public static Identifier modelAt(BlockPos pos){var cells=chunks.get(ChunkPos.pack(pos));return cells==null?null:cells.get(pos);}
@@ -65,15 +73,15 @@ public final class FourneauModels {
private static final class FacadeModel extends WrapperBlockStateModel {
private final Map<Identifier,BlockStateModel> tiled;
FacadeModel(BlockStateModel original,Map<Identifier,BlockStateModel> tiled){super(original);this.tiled=tiled;}
private BlockStateModel select(BlockPos pos){var id=modelAt(pos);return id==null?wrapped:tiled.getOrDefault(id,wrapped);}
private BlockStateModel select(BlockPos pos,BlockState state){var id=modelAt(pos,state);return id==null?wrapped:tiled.getOrDefault(id,wrapped);}
@Override public void emitQuads(QuadEmitter emitter,BlockAndTintGetter view,BlockPos pos,BlockState state,RandomSource random,Predicate<Direction> cull){
select(pos).emitQuads(emitter,view,pos,state,random,cull);
select(pos,state).emitQuads(emitter,view,pos,state,random,cull);
}
@Override public Object createGeometryKey(BlockAndTintGetter view,BlockPos pos,BlockState state,RandomSource random){
var model=select(pos);return model==wrapped?wrapped.createGeometryKey(view,pos,state,random):model;
var model=select(pos,state);return model==wrapped?wrapped.createGeometryKey(view,pos,state,random):model;
}
@Override public Material.Baked particleMaterial(BlockAndTintGetter view,BlockPos pos,BlockState state){return select(pos).particleMaterial(view,pos,state);}
@Override public int materialFlags(BlockAndTintGetter view,BlockPos pos,BlockState state,RandomSource random){return select(pos).materialFlags(view,pos,state,random);}
@Override public Material.Baked particleMaterial(BlockAndTintGetter view,BlockPos pos,BlockState state){return select(pos,state).particleMaterial(view,pos,state);}
@Override public int materialFlags(BlockAndTintGetter view,BlockPos pos,BlockState state,RandomSource random){return select(pos,state).materialFlags(view,pos,state,random);}
}
private FourneauModels(){}
}
@@ -90,6 +90,8 @@ public final class HotbarClient {
}
return true;
});
// This workshop exposes only the current hotbar; Tab remains native focus navigation.
if (screen instanceof ClayWorkshopScreen) return;
ScreenKeyboardEvents.afterKeyRelease(screen).register((s,event) -> {if (key.matches(event)) held=false;});
ScreenKeyboardEvents.allowKeyPress(screen).register((s,event) -> {
if (!ProgressionClient.active() || !key.matches(event) || event.isEscape() || editing(s.getFocused())) return true;
@@ -14,12 +14,15 @@ import net.minecraft.world.item.SpawnEggItem;
public final class InhabitantCompanionWidget extends AbstractWidget {
private final Mob model;
public InhabitantCompanionWidget(int width,int height,ItemStack egg) {
this(width,height,egg,true);
}
public InhabitantCompanionWidget(int width,int height,ItemStack egg,boolean baby) {
super(0,0,width,height,egg.getHoverName());
var client=net.minecraft.client.Minecraft.getInstance();
var entity=client.level!=null && egg.getItem() instanceof SpawnEggItem
? SpawnEggItem.getType(egg).create(client.level,EntitySpawnReason.MOB_SUMMONED) : null;
model=entity instanceof Mob mob ? mob : null;
if(model!=null){model.setId(Integer.MIN_VALUE);model.setBaby(true);model.setPos(client.player.position());model.setCustomName(egg.getCustomName());}
if(model!=null){model.setId(Integer.MIN_VALUE);model.setBaby(baby);model.setPos(client.player.position());model.setCustomName(egg.getCustomName());}
}
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int mouseX,int mouseY,float partial) {
if(model==null)return;

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