Compare commits

...
Author SHA1 Message Date
koka f4d3dad1ca Align Hello World previews and centre onboarding controls (beta.264)
Build Sanctuary / build (push) Canceled after 0s
2026-10-16 23:28:55 +02:00
koka 653a063b15 Show native baby familiar previews in Hello World (beta.263) 2026-10-16 07:23:17 +02:00
koka 9d3bc0cee2 Redesign Hello World with native hotbar and 3D previews (beta.262) 2026-10-16 03:58:47 +02:00
koka 5c474a8489 Deliver familiar lab, respawn fixes and unified Alt controls (beta.261)
Build Sanctuary / build (push) Canceled after 0s
Add the isolated familiar combat arena and native checks, restore familiar commands after respawn, and share tap/hold Alt for attacks and orders with FR/EN help. Record the beta.259–261 changes and keep mod and pack manifests aligned at beta.261.

Validation: ./gradlew check build assemblePack assembleTestPack -PsanctuaryFocusedTests=eggaudit,familiar,familiar260,accessories -PsanctuaryAtlasOnly=true; all 44 required GameTests passed.
2026-10-15 20:11:27 +02:00
koka 20d68ea7ee Fix cliff shadows near solar noon and delay menu music (beta.258) 2026-10-09 14:27:42 +02:00
koka bf9b8fca14 Separate optional client mods and set sun shafts to 20% (beta.257) 2026-10-09 09:38:06 +02:00
koka bacebf8523 Optimize Ambiance volume and restore Sodium surface effects (beta.256) 2026-10-09 04:47:06 +02:00
koka 2a4dcec63d Deliver Ambiance leaf shadows, profile menus and clearer shafts in beta.255 2026-10-09 01:46:38 +02:00
koka 1a00a52c50 Rename the shader to Ambiance in French and English 2026-10-08 23:02:14 +02:00
koka 1116b29ceb Optimize Vulkan shader budgets and presets in beta.254 2026-10-08 22:56:01 +02:00
koka 833f14d9f9 Play supplied Sanctuary menu soundtrack in beta.253
Build Sanctuary / build (push) Canceled after 0s
2026-10-08 20:53:16 +02:00
koka f90d6e2928 Embed Sound Physics Remastered with native opt-in settings in beta.252 2026-10-08 20:30:43 +02:00
koka 8e512502fd Preserve verified beta.251 insect habitat baseline 2026-10-08 20:23:03 +02:00
koka 126a70f217 Deliver beta.250 insects, sounds and review gallery
Build Sanctuary / build (push) Canceled after 0s
Add the 47 supplied Wild World insects with calendar-aware habitats,
animated sprites, a 16x16 net, silent captures and natural positional audio.
Ground hoppers now travel in arcs and flee through rapid consecutive hops.
Include the isolated noon/clear review map and full texture export tool.

Consolidate the previously validated beta.242-244 zipline/grappling and
player-head/chunk-synchronized map changes retained in this checkout.

Validation: check build assemblePack assembleTestPack succeeded;
all 16 required insect GameTests and the native Vulkan client passed.
Both beta.250 MRpacks have verified versions and embedded JARs.
Runtime worlds, generated JARs and build artifacts remain ignored.
2026-10-08 17:46:53 +02:00
koka 927f961a79 Separate elevator networks and simplify Waystone menu (beta.241) 2026-10-07 17:19:34 +02:00
koka 850ab17d63 Set Waystone travel to one XP point per sixteen blocks 2026-10-07 16:45:07 +02:00
koka 68ce6de1ec Add original Waystones, XP travel and Large island house visit tests 2026-10-07 14:59:23 +02:00
koka 51e539a19a Consolidate current aptitudes and Smithing with restored Pause and M shortcut 2026-10-07 14:16:45 +02:00
koka 79952c3f65 Revert fullscreen Pause map layer while keeping Story and M shortcut
Build Sanctuary / build (push) Canceled after 0s
Reverts the layout experiment from 5ea7950 at the creator request. Restore beta.235 map presentation, retain 94fefc0, and withdraw the old trial pack without creating a new release.
2026-10-06 23:44:56 +02:00
koka 0f918899fb Deliver beta.236 with fullscreen Pause map and configurable Atlas shortcut 2026-10-06 22:26:18 +02:00
koka 94fefc0442 Add configurable M shortcut for Atlas from game and Pause 2026-10-06 22:22:30 +02:00
koka 5ea7950f23 Render Atlas behind centered Pause controls with a centered North indicator 2026-10-06 22:12:26 +02:00
koka 309d0ba6f3 Add permanent Smithing aptitude for anvils and smithing tables in beta.238
Build Sanctuary / build (push) Canceled after 0s
2026-10-06 16:12:32 +02:00
koka 5dee98106e Keep purchased aptitudes permanent and integrate Pause in beta.237
Build Sanctuary / build (push) Canceled after 0s
2026-10-06 14:43:01 +02:00
koka d6f3258442 Merge verified beta.235 Pause layout into progression aptitudes 2026-10-06 14:35:34 +02:00
koka 1c8e6437d5 Group aptitudes and unlock enchanting and alchemy in beta.236 2026-10-06 14:25:15 +02:00
417 changed files with 18841 additions and 604 deletions
+335 -3
View File
@@ -6,6 +6,336 @@ Créer un monde **Sanctuary** en Petit, Moyen ou Grand, puis activer les ancres
correspondantes. Le module Sanctuary Test n’est pas requis.
[Intégration et vérification du pack beta.224](docs/four-islands-base224.md).
## beta.264 — Menu central et aperçus alignés
[Formulaire de la largeur de la hotbar native](docs/hello-layout-beta264.md) :
couleur, biographie, choix et nom du familier regroupés au centre. Pseudo et
nom au-dessus des aperçus 3D, sol commun et familiers bébé miniatures.
Détails de l'espèce uniquement au survol, titre Hello World retiré.
Retour séparé, Entrer dans Sanctuary dans le pied de page natif des menus.
Build, 13 GameTests serveur et parcours Vulkan FR/EN GUI 2/3/4 réussis,
avec 44 modèles bébé, admission, reconnexion et code d'accès vérifiés.
[Pack local beta.264](build/Sanctuary-beta.264.mrpack).
## beta.263 — Bébés familiers dans Hello World
[Formes bébé natives dans l'aperçu 3D](docs/hello-baby-familiars-beta263.md) :
modèles, textures et échelles d'âge adaptés au familier remis en jeu, avec
suivi de la souris. Les 44 aperçus sont comparés aux proxies natifs et vérifiés
à froid en Vulkan FR/EN GUI 2/3/4. Build, 13 GameTests serveur et export
du [pack local beta.263](build/Sanctuary-beta.263.mrpack) vérifiés.
## beta.262 — Nouveau menu Hello World
[Personnage et familier dans un menu centré](docs/hello-world-menu-beta262.md) :
aperçu du personnage à gauche, familiers à droite, biographie et nom au centre
dans deux blocs symétriques. Texture native de hotbar pour neuf couleurs par
page, flèches autour du pseudo, fond texturé uniforme et aperçus 3D qui suivent
la souris. Saisies et sélection conservées, libellés FR/EN.
Build, 13 GameTests serveur et parcours Vulkan FR/EN GUI 2/3/4 réussis,
avec les 44 modèles de départ, création, reconnexion et code d’accès vérifiés.
[Pack local beta.262](build/Sanctuary-beta.262.mrpack).
## beta.261 — Alt bref et Alt maintenu
[Un seul raccourci pour l’attaque et les ordres](docs/familiar-alt-beta261.md) :
Alt gauche bref attaque ou reprend le suivi ; Alt maintenu ouvre les ordres
sans envoyer d’attaque rapide. H est retiré des commandes du familier ; G garde
la technique. Raccourci unique configurable, libellés FR/EN, aides du labo
actualisées. Les correctifs de réapparition beta.260 sont conservés.
Build, **44 GameTests serveur** et parcours **Vulkan** réussis, avec maintien
sans attaque rapide et commandes après réapparition native.
[Pack local beta.261](build/Sanctuary-beta.261.mrpack).
## beta.260 — Familiers après la mort et attaque sur Alt
[Ordres et œufs réutilisables après réapparition](docs/familiar-respawn-beta260.md),
avec keep inventory ou récupération dans la tombe. État de l’ancienne vie retiré,
identité, santé et recharges conservées. Alt gauche pour l’attaque rapide,
H maintenu pour les ordres, G pour la technique ; aide redondante retirée sous
le nom du familier équipé. Labo fermé après les retours.
Build, **44 GameTests serveur** et parcours client **Vulkan** réussis,
avec mort/réapparition native, touches reconfigurées et attaque réelle.
[Pack local beta.260](build/Sanctuary-beta.260.mrpack).
## beta.259 — Arène des familiers
[Carte de test isolée](docs/familiar-lab-beta259.md) : cinq coffres avec les
88 espèces, zombie immobile à vie infinie et compteur de dégâts, bouton
d’hostile aléatoire parmi 23 types, nettoyage et remise à zéro. Personnage de
test en Survie, blocs protégés, ordres et combat actuels conservés.
Build, 15 GameTests ciblés et contrôle natif de l’arène avec attaque réelle
d’un loup réussis ; un ancien test de transfert d’œuf reste en échec dans
la suite élargie. Visite locale `review-02` effectuée en Vulkan, puis fermée
après les retours de séance.
La croissance et le creeper qui court puis explose restent à développer.
[Pack Test local beta.259](build/Sanctuary-Test-beta.259.mrpack).
## beta.258 — Ombres des falaises et musique du menu
[Correction des bandes sur les falaises autour du zénith](docs/zenith-shadows-beta258.md),
avec les vraies ombres des surplombs conservées et les mêmes budgets de carte.
La musique démarre trois secondes après le chargement Mojang au menu titre,
continue dans Options et s’arrête avant Hello World et l’apparition du monde.
Build, six GameTests serveur, falaises de 10 h à 14 h, feuilles, stabilité
caméra et canal audio natif validés sur Windows Vulkan avec 8 Gio alloués.
[Pack local beta.258](build/Sanctuary-beta.258.mrpack).
## beta.257 — Mods client séparés et rayons à 20 %
[JEI Sanctuary, Sound Physics Remastered et Sodium](docs/external-mods-beta257.md)
sont des JAR séparés et facultatifs, fournis par défaut. Leur retrait avant
lancement conserve les règles serveur, les connaissances et la fabrication
manuelle ; le catalogue nécessite le fork JEI fourni. Sun shafts à **20 %**
dans les profils et pour les valeurs neuves, préférences personnalisées conservées.
Build, huit GameTests serveur sans JEI/Sound Physics, quatre combinaisons client
Windows Vulkan avec 8 Gio, transfert JEI et réglages audio validés.
[Pack local beta.257](build/Sanctuary-beta.257.mrpack).
## beta.256 — Ambiance, Sodium et volume solaire
[Rayons détaillés à 10 %, portée réglable et reconstructions réparties](docs/ambiance-volume-performance-beta256.md).
Sodium 0.9.2 pour Minecraft 26.3 inclus dans le pack client ; minerais,
bloom et PBR rétablis avec son rendu. Le SSR reste activé en Moyen.
Ultra ne sélectionne aucune portée de 256 blocs ; ces choix restent manuels
et indiqués comme expérimentaux. Sur le trajet de test Grand à 32 chunks,
1080p et 8 Gio de mémoire Java, Moyen passe de 135 à 321 FPS moyens,
Personnalisé de 72 à 113. Des pics persistent : 144/240 FPS constants ne
sont pas garantis. Build, GameTests et contrôles natifs Vulkan vérifiés.
[Pack local beta.256](build/Sanctuary-beta.256.mrpack).
## beta.255 — Feuilles, menus et rayons Ambiance
[Les ombres utilisent l’alpha des vraies textures de feuilles](docs/ambiance-leaves-menus-beta255.md),
avec davantage de détails en Faible et le SSR conservé en Moyen.
Les réglages existants sont rangés dans six catégories FR/EN et un sous-menu
Ombres. Import/export de profils Ambiance `.txt`, budget Personnalisé compris.
Sun shafts rendus plus visibles en Moyen, avec le même budget de calcul.
Sur la canopée de test à 1080p, le surcoût de rendu mesuré est de 0,035–0,047 ms
pour Moyen et 0,035–0,061 ms pour Faible ; ce ne sont pas des mesures GPU
isolées ni des écarts de FPS en jeu. Build, feuilles, profils, menus et effets
validés sur Vulkan RX 9070 XT.
[Pack local beta.255](build/Sanctuary-beta.255.mrpack). Les dégradés du ciel
seront retravaillés lors de la prochaine passe.
## beta.254 — Profils de shader et budget Vulkan
[Profils Patate, Faible, Moyen, Élevé et Ultra](docs/shader-vulkan-optimization.md)
dans les Options du shader **Ambiance**, avec réglages avancés et conservation
des préférences existantes.
Moyen inclut le **SSR**, le PBR, le SSGI léger, les sun shafts et les minerais
émissifs. Cache de la carte d'ombres commune, budgets de calcul par profil
et portée d'émission bornée. La transmission des feuilles reste procédurale.
Build, pack et suites Windows Vulkan sur RX 9070 XT validés. Moyen avec SSR
réduit le temps de rendu mesuré de 29 % à 38 % sur la scène de test ;
ces mesures ne sont pas des gains de FPS garantis dans un monde réel.
[Pack local beta.254](build/Sanctuary-beta.254.mrpack).
## beta.253 — musique du menu Sanctuary
[Musique fournie pour le menu](docs/menu-music-beta253.md), convertie en OGG
Vorbis stéréo et embarquée dans le mod. Lecture immédiate au menu titre,
continue dans les sous-menus, avec le volume Musique de Minecraft.
Le morceau entier dure environ 2 min 45 et recommence à la fin.
Compilation, quatre GameTests serveur et lecture native Vulkan validés.
[Pack local beta.253](build/Sanctuary-beta.253.mrpack) vérifié.
## beta.252 — Sound Physics Remastered en option
[Acoustique réaliste](docs/sound-physics-beta252.md) embarquée dans Sanctuary,
désactivée par défaut pour une configuration neuve. Activation et réglages
FR/EN dans **Options → Sound Physics Remastered**, sans installation séparée
ni Cloth Config. Les préférences existantes sont conservées ; socle beta.251
avec les habitats d'insectes et la protection des maisons.
Build, 21 GameTests serveur et essai Vulkan réussis.
[Pack local beta.252](build/Sanctuary-beta.252.mrpack) vérifié.
## beta.251 — Habitats des insectes et protection des maisons
[Apparitions naturelles](docs/insect-habitats-beta251.md) : sauterelles et
criquets sauteurs dans l’herbe dégagée, libellules dans l’herbe ou près de
l’eau douce, cloportes sur ou près des pierres. Poids des sauteurs réduits,
plafonds locaux par groupe, saisons et horaires conservés. Les toits en bois,
pierre, verre ou dalles bloquent les apparitions et les trajectoires libres ;
les feuilles conservent les habitats forestiers. Build, 21 GameTests requis
et client Vulkan réussis. [Pack local beta.251](build/Sanctuary-beta.251.mrpack)
et [pack Test](build/Sanctuary-Test-beta.251.mrpack) vérifiés.
## beta.250 — Sauterelles qui avancent en sautant
[Bonds avec déplacement](docs/jumping-insects-beta250.md) pour la sauterelle,
le criquet long et le criquet migrateur : trajectoires en arc vers le sol,
pause après l’atterrissage, bonds rapides en fuite. Arrivées sèches et parcours
sans collision dans les chunks déjà chargés. Build, 16 GameTests requis et
client Vulkan réussis, dont déplacement et fuite déclenchée en plein saut.
[Pack local beta.250](build/Sanctuary-beta.250.mrpack) vérifié.
Le dossier de textures beta.249 reste dans Téléchargements.
## beta.249 — Coccinelle et dossier des textures
[Coccinelle en billboard](docs/ladybug-textures-beta249.md), posée sur la fleur
ou les feuilles. Export local des textures du client : 4 671 images à modifier,
métadonnées d’animation, originaux classés par source, inventaire CSV/JSON.
Dossier et ZIP dans Téléchargements ; aucune image originale retouchée.
Build, 13 GameTests requis et client Vulkan réussis ; trois angles de vue
vérifiés et point d’appui corrigé. Packs locaux vérifiés.
## beta.248 — Libellules, sons et galerie au soleil
[Corps des libellules en croix, neuf sons naturels](docs/insect-cross-sounds-beta248.md)
et galerie d’inspection à midi, ciel dégagé. Les chants sont espacés ; les
bourdonnements suivent la mouche et le moustique, et s’arrêtent à la fuite
ou à la capture. [Sources et crédits](docs/insect-audio-credits.md) inclus
dans le mod. L’escargot reste inspectable au sec ; sa règle de pluie demeure
en jeu normal. Treize GameTests requis et client Vulkan réussis (neuf sons
décodés, ciel clair, capture silencieuse). Packs locaux vérifiés. La Walker
cicada utilise une autre prise après le retour d’écoute ; visite `review-06`
ouverte en beta.248, lancement protégé des reconstructions. [Reçu final](build/insects248/validation.json).
## beta.247 — Retours sur les insectes
[Capture et comportements](docs/insect-behaviour-beta247.md) : clic droit
sur le plus proche visible à portée, observation immobile, escargot posé,
grillons et cloporte à plat, sauts plus hauts, puce sur son villageois.
Libellules à modèle dédié et vol progressif entre points ; bousier poussant
une boule de neige. [Sons à préparer](docs/insect-sounds-plan.md), sans audio
ajouté. Treize GameTests requis et contrôle client Vulkan réussis.
Nouvelle visite `review-04` ouverte en beta.247. `check build assemblePack
assembleTestPack` réussit ; [packs et reçu](docs/insect-behaviour-beta247.md)
vérifiés localement.
## beta.246 — Carte d’inspection des insectes
[47 parcelles numérotées](docs/insect-inspection-beta246.md) dans une instance
de développement jouable : supports adaptés, espèces présentes ensemble,
retour cinq secondes après fuite ou capture, navigation `/insectes`.
Icônes des 47 spécimens corrigées dans l’atlas ; filet en texture 2D 16 × 16.
`check build assemblePack assembleTestPack`, neuf GameTests requis et client
Vulkan réussis. Visite `review-03` ouverte, sprites 16 × 16 vérifiés en jeu.
[Pack local beta.246](build/Sanctuary-beta.246.mrpack) et
[pack Test](build/Sanctuary-Test-beta.246.mrpack) vérifiés.
## beta.245 — Insectes Wild World et filet
[47 espèces fournies](docs/insects-wild-world-beta245.md) : habitats, mois,
horaires et pics d’apparition selon le calendrier serveur. Papillons à deux
ailes animées, sprites sur troncs, au sol et sur l’eau ; moustique en orbite
inoffensive. Approche accroupie, fuite et capture au clic droit avec un filet
fabriqué avec deux bâtons et trois fils. Spécimens FR/EN conservés en items,
captures et découvertes sans notification.
`check build assemblePack`, neuf GameTests requis et parcours client Vulkan
réussis, avec capture native et découvertes silencieuses vérifiées.
[MRpack local beta.245](build/Sanctuary-beta.245.mrpack) vérifié.
## beta.244 — Carte Habitant au rythme des chunks reçus
[Relevés liés à la vue du joueur](docs/map-chunk-sync-beta244.md) : les chunks
effectivement envoyés par Minecraft sont inscrits dans les six couches de la
carte personnelle, sans les trous du relevé lent à 32 blocs. Carte ouverte
actualisée par changements, constructions visibles comprises ; carte Pause
conservée. Essai Vulkan réussi : déplacement de 320 blocs, 3 003 chunks reçus
avec une vue à 32, Pause FR/EN, relecture des relevés et révocation Atlas.
`check build assemblePack` et 14 GameTests requis réussis ; livraison locale,
sans mise à jour Prism. [MRpack beta.244](build/Sanctuary-beta.244.mrpack).
## beta.243 — Joueurs au premier plan sur la carte
[Visages de skin dans l’Atlas et Pause](docs/map-player-heads-beta243.md),
seconde couche incluse, au-dessus des propriétés, de la grille, des waystones
et des annonces suivies. Nom au survol et direction locale conservée.
Positions des joueurs distants fournies par le serveur, sans découvrir le
terrain. Les ascenseurs disparaissent des deux cartes, même avec un étage
inconnu ; les téléporteurs isolés découverts restent visibles.
`check build assemblePack`, dix GameTests requis et client Vulkan FR/EN
GUI 2/3/4 réussis. [MRpack local beta.243](build/Sanctuary-beta.243.mrpack)
vérifié ; instance Prism personnelle non mise à jour.
## beta.242 — Tyroliennes et grappin portatif
[Modèles et recettes de 26.2](docs/ziplines-grappling-beta242.md) importés :
poulies reliées par une laisse, trajets dans les deux sens. Le Grappling Rod
conserve le lancer chargé et utilise un câble temporaire ; un obstacle
contre le joueur coupe le trajet et déclenche une chute. Une laisse par
lancer, serveur autoritaire, libellés FR/EN. **Texture finale à faire par
le créateur** ; les textures 26.2 sont provisoirement conservées.
`check build assemblePack`, dix GameTests requis et client Vulkan réussis.
[MRpack local beta.242](build/Sanctuary-beta.242.mrpack) vérifié.
## beta.241 — Ascenseurs séparés et menu Waystone
[Deux réseaux séparés côté serveur](docs/waystones-menu-beta241.md) : les
pierres alignées servent uniquement d’ascenseur ; les pierres isolées
voyagent entre maisons découvertes. Recherche par nom, boutons centrés sans
Y, distance et XP uniquement au survol. **R** renomme, Entrée enregistre,
Échap annule ; les actions d’étage sont indiquées par **jump / sneak**.
Tarif persistant avec `/sanctuary waystone cost 1 16` (défaut), ou un coût
direct par bloc avec `cost 0.0625`. M et les marqueurs sont conservés.
Avec 10 niveaux sur Grand graine 0 : **29 trajets de quartier**, **10 longs
trajets**, ou **160 déplacements d’étage** dans le réseau séparé. Build,
9 GameTests ciblés, deux tests Grand et client Vulkan FR/EN GUI 2/3/4 réussis.
[MRpack local beta.241](build/Sanctuary-beta.241.mrpack) vérifié.
## beta.240 — Waystones : 1 point d’XP pour 16 blocs
[Tarif ajusté et simulation relancée](docs/waystones-tarif-beta240.md) :
prix serveur **1 point d’XP / 16 blocs**, distance 3D arrondie au supérieur,
menus FR/EN synchronisés sur ce tarif. Sur le même Grand neuf graine 0 et
avec 10 niveaux sans regain : **29 trajets de quartier**, **10 longs trajets**,
ou **46 trajets mixtes**, dont 21 déplacements d’étage.
`check build assemblePack`, 7 GameTests ciblés, deux tests Grand et client
Vulkan FR/EN GUI 2/3/4 réussis.
[MRpack local beta.240](build/Sanctuary-beta.240.mrpack) vérifié.
## beta.239 — Waystones et visites entre maisons
[Waystones horizontales et étages](docs/waystones-beta239.md), texture originale
26.2 et renommage direct. Prix serveur : **1 point d'XP / 8 blocs**, distance
3D arrondie au supérieur. Maisons à découvrir ; étages accessibles avec
Saut / S'accroupir sans découverte préalable. Marqueurs dans l'Atlas et Pause,
**M** configuré pour ouvrir la carte. Aptitudes et Forgerie beta.238 réunies
avec la disposition Pause restaurée.
Sur un nouveau Sanctuary Grand graine 0, **10 niveaux = 160 points** :
**15 trajets de quartier**, **4 longs trajets d'île**, ou **26 trajets mixtes**
dont 12 déplacements d'étage. [Mesures et limites](docs/waystones-beta239.md).
`check build assemblePack`, 21 GameTests ciblés, deux tests sur Grand natif
et client Vulkan FR/EN GUI 2/3/4 réussis.
[MRpack local beta.239](build/Sanctuary-beta.239.mrpack) vérifié.
## Essai de carte derrière Pause — annulé
[L'essai plein écran](docs/pause-map-layer-beta236.md) est annulé à la demande
du créateur. Pause retrouve la disposition beta.235, avec la carte au centre
et une colonne de navigation à gauche lorsque l'Atlas est déverrouillé.
Story et **M**, configurable dans les contrôles, sont conservés.
Parcours client Vulkan FR/EN GUI 2/3/4 et `check build` de l'annulation réussis.
L'ancien pack d'essai beta.236 est retiré des artefacts actifs ; aucune nouvelle
livraison n'est créée par cette annulation. Les changements conservés sont
réunis avec les aptitudes et Forgerie dans la livraison beta.239.
## beta.238 — Forgerie
[Aptitude Forgerie](docs/forgerie-beta238.md) dans Arts et métiers : quatre
niveaux pour débloquer les enclumes et la table de forge, achat définitif.
Les réparations, noms, améliorations en netherite et ornements conservent
leurs règles natives. L'achat est contrôlé côté serveur, y compris pour les
postes portés sur la tête. Icône fournie et libellés FR/EN intégrés.
Build, 21 GameTests requis et parcours Vulkan FR/EN GUI 2/3/4 réussis.
[MRpack local beta.238](build/Sanctuary-beta.238.mrpack) vérifié, sans
publication ni mise à jour personnelle.
## beta.237 — Aptitudes acquises définitivement
[Aptitudes par thème et achats permanents](docs/aptitudes-beta237.md) : seuls
World Map et Mob Names sont activables/désactivables. Les dix autres aptitudes
restent utilisables après achat ; Enchantement et Alchimie coûtent chacun
quatre niveaux et débloquent l'accès personnel aux postes natifs. Icônes
fournies intégrées, libellés FR/EN. Les anciens choix de désactivation de ces
achats sont effacés sans toucher aux achats ni à l'XP. Disposition Pause
beta.235 intégrée, adaptée au choix de masquer la carte. `check build assemblePack`,
37 GameTests requis et parcours Vulkan FR/EN GUI 2/3/4 réussis.
[MRpack local beta.237](build/Sanctuary-beta.237.mrpack) vérifié ; aucune
publication ni mise à jour personnelle.
## beta.235 — Essai réversible du menu Pause
[Disposition adaptative](docs/pause-columns-beta235.md) : avant Atlas, Aventure
@@ -3178,9 +3508,11 @@ sauvegarde 26.2 : aucun outil de migration de ses chunks n'est livré ici.
It's Alive !, Only Fun et Master Key restent des modules autonomes prévus par la
vision. Leurs sources 26.2 ne sont pas copiées dans ce socle. Fabric API est
requise ; Demeure et le port source de JEI pour 26.3 sont imbriqués
dans le JAR Sanctuary. Leurs sources ou leur procédure de reconstruction et
leurs attributions figurent dans `mods/demeure/` et `mods/jei/`.
requise ; Demeure appartient au code interne de Sanctuary. Depuis beta.257,
le port source de JEI pour 26.3 et Sound Physics sont des mods client séparés
et facultatifs, comme Sodium. Leurs sources ou procédures de reconstruction
et leurs attributions figurent dans `mods/demeure/`, `mods/jei/` et
`mods/sound-physics-remastered/`.
## Travailler par tickets
+70 -1
View File
@@ -1,5 +1,27 @@
# Provenance et crédits
Depuis beta.256, le pack client inclut [Sodium 0.9.2+mc26.3](https://modrinth.com/mod/sodium/version/bAZQdGpg),
de JellySquid et des contributeurs CaffeineMC, sous Polyform-Shield-1.0.0.
Le JAR officiel est une dépendance séparée téléchargée par packwiz ; il n’est
pas intégré au code ni au JAR Sanctuary. La version et son SHA-512 sont fixés
dans `packwiz/mods/sodium.pw.toml`. Les [sources de Sodium](https://github.com/CaffeineMC/sodium)
et la licence de sa distribution conservent leurs auteurs et notices.
La musique du menu beta.253 provient du fichier `sanctuary-menu.m4a` fourni par
le créateur le 8 octobre 2026 pour son intégration dans Sanctuary. Le fichier
original reste intact ; sa piste audio complète est convertie en OGG Vorbis
stéréo à 48 kHz, qualité 6 avec libvorbis 1.3.7. Les empreintes du fichier fourni
et de l'OGG sont dans
`mods/sanctuary/src/main/resources/licenses/Menu-Music-ASSET.json`, également
embarqué dans le JAR sous `licenses/Menu-Music-ASSET.json`.
Depuis beta.257, Sound Physics Remastered **1.5.1+26.3**, GPL-3.0-only,
est un JAR client séparé et facultatif (imbriqué entre beta.252 et beta.256). Sonic Ether, vlad2305m, Max Henkel et
Saint conservent leurs crédits. Le binaire officiel et son SHA-512 sont figés ;
voir `mods/sound-physics-remastered/PROVENANCE.md` pour les sources, la bibliothèque
ConfigBuilder incorporée et l'adaptation des options. Le JAR Sanctuary contient
la licence et cette provenance dans `licenses/sound-physics-remastered/`.
Le code Sanctuary est distribué sous GPL-3.0-or-later, comme la version 26.2 dont
la génération est reprise. Voir `LICENSE` et `docs/migration-26.2.md` pour la
provenance détaillée et le périmètre du portage.
@@ -91,7 +113,8 @@ aucune copie des textures de mobs ni aucun ancien pouvoir n’est embarqué.
Just Enough Items, mezz, MIT : fork source de la branche 26.2, commit
`aae2dfcfb82e6b5bce787ba72bb2eda7339b274f`, adapté à Minecraft 26.3-pre-2.
Le JAR contient la licence d’origine et [la provenance du portage](mods/jei/PROVENANCE.md).
Depuis beta.257, le fork Minecraft 26.3 `30.32.0-sanctuary.4` est un JAR client
séparé et facultatif, avec la licence d’origine et [la provenance du portage](mods/jei/PROVENANCE.md).
Les patches et la reconstruction sont fournis ; les téléchargements restent ignorés.
Baked Substring Index 0.1.0 (MIT, James Mitchell) et Suffix Tree 1.1.0
(Apache-2.0, Alessandro Bahgat Shehata) sont inclus avec leurs licences.
@@ -143,3 +166,49 @@ embarqué comme dépendance imbriquée, sans modification, pour le stockage
communautaire optionnel. Sa licence est fournie dans `licenses/mariadb-connector-j-LGPL-2.1.txt` du JAR Sanctuary.
Sources correspondantes : https://github.com/mariadb-corporation/mariadb-connector-j/tree/3.5.10
(distribution Maven Central 3.5.10).
## Tyroliennes et grappin beta.242
Poulies, modèles, recettes, textures provisoires et rendu segmenté des câbles
repris du module `iliketomoveit` de Sanctuary 26.2 (KOKA99CAB), référence
`26f2c2a0061619df1f146f9081d6740c560a7497`, sous GPL-3.0-or-later.
Les sources Beta adaptent les API Minecraft 26.3 et remplacent le balancement
par une tyrolienne portative temporaire. Le dépôt historique reste inchangé.
Les trois PNG provisoires sont copiés octet pour octet ; la texture finale
est réservée au créateur. Le rendu du câble utilise les laisses natives
Minecraft ; les modèles de poulie référencent les textures natives fer
et béton noir de Mojang/Microsoft. Aucun JAR 26.2 n’est redistribué.
## Insectes beta.245
Les 47 sprites 16 × 16 d’**Animal Crossing: Wild World** ont été fournis par
le créateur dans son dossier `insect`, le 8 octobre 2026. Ils sont conservés
octet pour octet dans `assets/sanctuary/textures/insect/`. **Nintendo** reste
titulaire des éléments graphiques du jeu ; la licence GPL du code Sanctuary
ne leur attribue pas une licence libre. Les noms des fichiers fournis et
leurs SHA-256 sont consignés dans [l’inventaire](docs/insects-assets.json).
Les habitats, mois, horaires et pics suivent la
[liste Nookipedia Wild World](https://nookipedia.com/wiki/Bug/Wild_World),
consultée le 8 octobre 2026 (révision 1240938). Les adaptations Minecraft,
le calendrier numérique et les limites sont décrits dans
[le ticket INSECT-01](docs/insects-wild-world-beta245.md).
Le modèle du filet référence les textures natives de Minecraft sans les copier.
## Sons des insectes beta.248
Neuf extraits naturels de Wikimedia Commons et Freesound sont fournis dans
`assets/sanctuary/sounds/insects/`. Ils conservent leurs licences propres,
distinctes du code GPL : CC0, CC BY 2.1 Japan, CC BY 3.0, CC BY 4.0 et
CC BY-SA 3.0. L’adaptation de la cigale du soir
reste sous CC BY-SA 3.0. Auteurs : ISAKA Yoji (Cory), Σ64,
あおもりくま (aomorikuma), ClaudeDebussy, YaCoUYaCoU, AmberdeMeillon et IanFSA.
Découpes, filtrage, réduction de bruit, volume, mono OGG et fondus par Sanctuary.
Les [crédits et liens de licences](docs/insect-audio-credits.md) et
[l’inventaire des fichiers](docs/insect-audio-assets.json) sont aussi inclus
dans le JAR sous `licenses/Insect-Audio-CREDITS.md` et
`licenses/Insect-Audio-ASSETS.json`. Les trois fichiers Freesound utilisent
leurs aperçus publics de haute qualité ; la cigale du soir utilise la
conversion MP3 publique de Commons. Aucun enregistrement d’Animal Crossing
n’est inclus. L’identification biologique du grillon reste non précisée ;
la courtilière européenne sert d’analogue sonore.
+10 -1
View File
@@ -3,6 +3,15 @@ plugins {
id 'net.fabricmc.fabric-loom' version "${loom_version}" apply false
}
allprojects {
repositories {
exclusiveContent {
forRepository { maven { url = 'https://api.modrinth.com/maven' } }
filter { includeGroup 'maven.modrinth' }
}
}
}
tasks.named('build') { dependsOn(':sanctuary:build', ':jei:build', ':sanctuary-test:build') }
tasks.named('check') { dependsOn(':sanctuary:check', ':jei:check', ':sanctuary-test:check', 'verifyPack') }
@@ -15,7 +24,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', 'assembleResourceTemplate')
dependsOn(':sanctuary:build', ':jei:build', 'verifyPack', 'assembleResourcePack', 'assembleResourceTemplate')
commandLine('python3', 'scripts/pack.py', 'assemble')
}
+180
View File
@@ -0,0 +1,180 @@
# SHADER-255 — Feuilles, menus et rayons Ambiance
Socle beta.254 (`1a00a52`), branche `codex/ambiance-leaves-menus-beta255`,
Minecraft 26.3, Java 25, livraison locale beta.255 vérifiée.
Demande du 9 octobre 2026 : remplacer le bruit des ombres de feuilles par
leurs textures, conserver un résultat visible en Moyen et Faible, ranger
les réglages actuels dans des sous-menus et permettre l'import/export.
Retour pendant les essais : les sun shafts activés restent trop discrets ;
leur visibilité en Moyen fait également partie de cette livraison.
Les meilleurs dégradés du ciel sont réservés à la passe suivante.
## Rendu des feuilles
La passe de profondeur utilise les faces du modèle natif des feuilles,
leurs UV, rotations et offsets. Elle lit l'alpha de l'atlas de blocs chargé,
au niveau zéro et au seuil de découpe 0,5 du terrain natif. Les texels
transparents ne déposent ni profondeur ni marque de feuillage. Un obstacle
opaque plus loin conserve son ombre. Le motif de bruit est supprimé.
Les feuilles superposées contribuent selon leurs trous réels, sans objectif
artificiel de 22 % d'opacité par surface. Plusieurs faces peuvent fermer
un passage suivant l'angle du soleil ; une canopée serrée ne garantit donc
pas des trous éclairés dans chaque zone observée.
L'atlas est emprunté au jeu : aucun duplicata de ses textures, lecture GPU
côté production ou analyse CPU des PNG par image. Les UV sont ajoutés au
maillage borné des casteurs : 24 octets par sommet contre 16 auparavant,
toujours sous la limite de 96 Mio du cache. Une ressource/modèle rechargé
reconstruit ce cache. Un atlas de feuilles animé impose un redessin au tick ;
les textures statiques conservent la réutilisation de beta.254.
Moyen conserve sa carte maximale 2048², sa grille 16 pixels/bloc et sa portée
64 blocs. Faible passe de 8 à 16 pixels/bloc et de 1024² à 2048² pour préserver
les détails ; sa portée reste 32 blocs et ses effets lourds restent coupés.
Ses deux textures de carte passent de 5 à 20 Mio, hors allocations du reste
du rendu. Les profils ne sont pas imposés aux réglages Personnalisé existants.
Patate conserve ses effets lourds désactivés. Le SSR reste inclus en Moyen.
## Menus et profils portables
Options → Shaders ouvre **Ambiance**. Le profil et l'interrupteur restent à
l'accueil. Les six catégories natives FR/EN sont Performance, Caméra et effets,
Éclairage, Atmosphère, Matériaux et reflets, Eau. Éclairage contient un
sous-menu Ombres. Tous les réglages antérieurs restent disponibles ; modifier
un effet passe en Personnalisé et conserve le budget choisi. Retour/Echap
reconstruit le parent pour actualiser le profil affiché.
L'accueil propose **Importer un profil** et **Exporter un profil**. Les fichiers
UTF-8 `.txt` contiennent des lignes `clé=valeur`, un identifiant de format
Ambiance et les réglages complets, y compris le budget Personnalisé et les
préférences d'eau pour chaque type de monde. La valeur `default` restaure
le défaut du type de monde, même si l'installation destinataire a un choix
explicite différent. Le dossier proposé est
`config/ambiance-profiles`. Le joueur peut saisir un nom ou un chemin complet,
ouvrir ce dossier, choisir un fichier existant dans la liste ou glisser un
fichier extérieur dans l'écran d'import. Un remplacement de fichier existant
utilise la confirmation native. Les réglages importés sont appliqués immédiatement.
L'import valide toutes les clés, types et plages avant de sauvegarder puis
remplacer la configuration active. Une erreur laisse les préférences actives
et persistées intactes. Les fichiers d'un autre shader ne sont pas des profils
Ambiance ; les clés de Complementary ne commandent pas son moteur Vulkan.
Aucune dépendance Iris n'est ajoutée.
Organisation inspirée des catégories du
[fichier officiel Complementary](https://github.com/ComplementaryDevelopment/ComplementaryReimagined/blob/main/shaders/shaders.properties),
et fichiers texte comme dans le
[guide Iris](https://github.com/IrisShaders/Iris/blob/26.1/docs/guide.md).
Le chantier concerne l'organisation des effets existants, conformément à la
précision du créateur ; il n'ajoute pas les nuages, AA ou fonctions absentes.
## Visibilité du volume solaire
Les captures activées de la première passe confirment le retour du créateur :
le signal du volume existe mais reste trop faible à l'œil. Le gain de diffusion
dans l'air clair passe de 0,018 à 0,16 ; Moyen conserve son intensité de 25 %,
ses huit segments et une seule lecture de profondeur par segment. Aucun
échantillon, passe, carte ou maillage supplémentaire n'est ajouté. La courbe
de compression de la lumière et les règles d'occlusion restent actives.
Le contrôle visuel cible désormais le contraste du premier plan au 95e
percentile avec le vrai budget Moyen, plutôt qu'une simple somme positive
sur tous les pixels. Les validations salle fermée, feuilles, retour de caméra,
météo et OFF/zéro passent avec ce gain. Moyen atteint 10 niveaux de luminance
au 95e percentile du premier plan à 25 %, avec son budget de huit segments
et une lecture. Le créateur retient la capture avec tous les effets de Moyen.
[Capture native retenue](../build/leaf255/0002_sanctuary-beta142-shader-shafts255-medium-combined.png).
## Coût mesuré
RX 9070 XT, Ryzen 7 3700X, Vulkan natif, 1920 × 1080, sans VSync ni limite
FPS. Socle beta.254 exact et candidat beta.255 : même monde plat neuf graine
122, sol blanc, canopée 41 × 41 de trois couches, même caméra. Deux séries
avec ordre Faible/Moyen inversé, 60 ticks de stabilisation puis 100 ticks
mesurés par profil. Mesure finale incluant le volume solaire retenu ; moyenne
des deux médianes de `renderFrame` :
| Soleil | Profil | beta.254 | beta.255 | Écart |
| --- | --- | ---: | ---: | ---: |
| Fixe | Faible | 0,505 ms | 0,540 ms | +7,0 % |
| Fixe | Moyen | 1,500 ms | 1,536 ms | +2,4 % |
| Mobile | Faible | 0,502 ms | 0,563 ms | +12,1 % |
| Mobile | Moyen | 1,509 ms | 1,556 ms | +3,1 % |
Cette passe améliore la fidélité et ne constitue pas un gain de performance.
Le coût supplémentaire mesuré est de 0,035–0,061 ms en Faible et de
0,035–0,047 ms en Moyen sur cette scène. La carte plus grande de Faible
réduit aussi la tolérance angulaire du cache : les redessins au soleil
mobile passent de 174–180 à 341–357 dans ces séries. Moyen reste à
347–348 contre 349–351 sur le socle.
Soleil fixe : aucun redessin pendant les mesures, dans les deux versions.
Ces durées incluent le temps CPU de rendu, soumission et attentes ; elles
n'isolent pas le GPU et ne se convertissent pas en FPS de jeu. Elles ne
prédisent pas toutes les forêts, packs de ressources ou distances. La portée,
la grille et le budget de géométrie continuent de limiter les ombres lointaines.
[Mesures originales](../build/leaf255/comparison.json).
## Validation
`check build assemblePack` réussi avec `sanctuaryFocusedTests=menus` et
`sanctuaryAtlasOnly=true`. Compilation des GameTests clients réussie sur Java 25.
Build final : 137 tâches, 2 min 52 ; quatre GameTests serveur requis et contrôles
purs réussis. Commande de livraison :
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=menus -PsanctuaryAtlasOnly=true
```
Validation graphique exclusivement Vulkan, dans
`%LOCALAPPDATA%\SanctuaryDev\leaf255-*` sur Windows,
à part de Prism et de ses sauvegardes ; aucun monde existant modifié.
- Oracle de textures contrôlées : 398–400 points conformes sur 400, avec
masques complémentaires, en Faible et Moyen. Les trous éclairés et ombres
suivent le fichier chargé ; captures des textures natives également réalisées.
- Rechargement de ressources, cache statique et alpha animé validés
(`LEAF255_ACCURACY_PASS`, variation lumineuse animée 70,06).
- Parcours FR/EN, six catégories et Ombres, retour au parent, réglage clavier,
export par bouton, import par liste et glisser-déposer validés. Le budget
Personnalisé survit à l'export/import et à la relecture disque ; les choix
d'eau explicites et par défaut sont restaurés sur une configuration différente.
Fichiers invalides ou étrangers rejetés sans modifier les préférences.
- Profils beta.254, SSR de Moyen, cache, déplacements, éditions et soleil
validés par `SHADER254_PROFILES_PASS`.
- Suite historique ombres/effets validée : soleil/lune, contours opaques,
casteurs hors écran, éditions, stabilité, rechargement, contours, bloom,
minerais, lens flare, SSGI rouge/bleu et occlusion, balayage de 12 blocs,
gros plans, PBR, redimensionnement et changements de dimension.
`PIXEL_SHADOWS122_CLIENT_PASS`, `EMISSIVE131_PASS`, `SSGI133_PASS` et
`PBR134_PASS` présents ; exécution réussie en 9 min 50.
- Volume solaire final : `SUNSHAFT255_VISIBILITY_PASS`, `SUNSHAFT137_PASS`
et `SUNSHAFT138_WEATHER_PASS`, exécution réussie en 3 min 43.
Les pièces fermées restent protégées et le feuillage atténue les rayons.
Énergie normalisée : clair 0,0333, brume 0,1839, pluie 0,0223, orage 0,0100.
- Restauration complète des profils, choix d'eau explicites et par défaut compris,
validée par `SHADER_OPTIONS255_PASS` après le correctif de portabilité.
- Mesure finale canopée : `LEAF255_PERF_PASS`, `LEAF255_PASS`, build natif
réussi en 1 min 41. Toutes les tâches de test temporaires sont supprimées.
[Reçu de validation et empreintes](../build/leaf255/validation.json).
Deux corrections de tests : le nouveau parcours clavier utilise les constantes
natives SDL de 26.3 après un premier essai avec des codes inadaptés, et la
canopée historique n'impose plus une proportion de trous issue du bruit
procédural. L'oracle contrôlé ci-dessus vérifie désormais les trous contre la
texture réellement chargée. Les contrôles isolent les effets pour mesurer
leur signal ; les captures combinées et le profil Moyen sont aussi vérifiés.
## Artefact local
[Sanctuary-beta.255.mrpack](../build/Sanctuary-beta.255.mrpack), 16 494 496 octets.
Minecraft 26.3, Fabric Loader 0.19.5, JAR embarqué identique au JAR construit ;
classes d'import/export, libellés FR/EN et shader utilisant l'alpha vérifiés.
SHA-256 du pack :
`71484981b3a11373596f1178182b812500a8f63f3180efc8338c8470408bdd2c`.
SHA-256 du JAR :
`62a3527ffdf2a5cf07ba2d42aad242e545d3556daecaca3ed31ba96eac49a492`.
Le canal public et l'installation personnelle ne sont pas mis à jour par cette
livraison locale. Les dégradés du ciel restent à traiter lors de la passe suivante.
+203
View File
@@ -0,0 +1,203 @@
# SHADER-256 — Volume solaire et chutes de FPS
Socle beta.255 (`2a4dcec`), branche `codex/ambiance-volume-performance-beta256`.
Livré localement en beta.256, Minecraft 26.3, Java 25, Vulkan exclusivement.
Demande du 9 octobre 2026 : supprimer les copies espacées des casteurs dans
les sun shafts, conserver des rayons détaillés à 10 % et rendre leur portée
réglable. Préserver les belles ombres de surface et le SSR de Moyen.
Diagnostiquer les chutes à 49 FPS en Personnalisé, vue 32 chunks, monde Grand,
sur Ryzen 7 3700X / RX 9070 XT à 1080p. Les comparaisons utilisent un plafond
Java de 8 Gio ; ce plafond ne simule pas une machine ayant 8 Gio physiques.
Le ciel est réservé à une passe ultérieure.
## Contrat de vérification
- Monde neuf Grand, graine 0, génération actuelle inchangée, vues terrain,
vide et trajet de caméra reproductible ; aucun monde personnel modifié.
- Temps entre images, temps CPU de rendu et trace JFR distingués. Ne pas
convertir les seuls temps de soumission CPU en FPS.
- Comparaison sans effets, ombres seules, volume seul, Moyen et Personnalisé.
- Carte de profondeur réutilisée au soleil lent ; modifications des blocs,
déplacements, changements de lumière et textures animées invalident le cache.
- Contrôles natifs du détail, de la subtilité, de l'occlusion et des contours,
plus retour caméra, OFF/zéro, pluie, rechargement et tailles impaires.
- Sodium : disponibilité exacte 26.3/Fabric et essai natif Vulkan avant toute
inclusion dans le pack. Pas de garantie de 144/240 FPS sans mesure réelle.
## Changements
Le volume utilise un pas constant en blocs, un filtrage des comparaisons de
profondeur et une reconstruction bilatérale. Au bord d’une silhouette trop
fine pour les pixels réduits, l’intégration reprend la profondeur du pixel
réel. Il n’y a pas d’historique temporel. L’intensité neuve et les profils
explicites sont à 10 % ; les valeurs explicites existantes sont conservées.
Portée indépendante de 8, 16, 24, 32, 48 ou 64 blocs et densité de 64, 96,
128 ou 192 échantillons par 32 blocs dans Atmosphère, FR/EN et import/export.
Moyen utilise 32 blocs / 96 ; Élevé 48 / 128 ; Ultra 48 / 192. La portée
effective reste bornée par les ombres. Réduire la portée réduit le nombre
d’échantillons ; réduire l’intensité seule ne réduit pas ce coût.
Aucun profil automatique ne sélectionne 256 blocs. Cette valeur reste
manuelle et porte la mention expérimentale pour les ombres, le PBR, le SSR
et les contours. Le SSR de Moyen reste activé. Le budget automatique des émetteurs du Custom
historique est également limité à 128 blocs, au lieu de 256.
Les bornes des casteurs sont alignées sur la cellule de 16 blocs de la carte
d’ombres : un déplacement à l’intérieur ne reconstruit plus ses bordures.
Le cache des formes inclut les états ignorés et évite les recherches répétées
de tags avant un accès déjà mémorisé. Les modèles et l’alpha des vraies
textures de feuilles restent ceux de beta.255.
Les index des chunks et sections des ombres, du bloom et du PBR utilisent
des clés entières sans allocation de `Long` à chaque recherche. Les quatre
constructeurs progressifs (ombres, PBR, émission et volume des lumières)
disposent chacun d’une tranche CPU de 0,5 ms en Patate/Faible/Moyen, 1 ms en
Élevé/Ultra/Personnalisé, au lieu de 2–3 ms. Le travail reprend à l’image
suivante et les meshes existants restent affichés pendant leur remplacement.
Il s’agit d’un budget souple : une opération individuelle ou un transfert
GPU ne peut pas être interrompu. La qualité géométrique et les échantillons
GPU ne changent pas ; une reconstruction initiale peut prendre plus d’images.
La trace JFR de beta.255 indique également un recensement de toutes les
sections chargées à chaque tick serveur, même sans demande de diagnostic.
`TerrainCensus` démarre maintenant au premier relevé explicite. Les commandes
conservent le budget progressif, les instantanés immuables et la lecture sans
chargement de chunks. Après activation, le recensement continue comme avant ;
son parcours actif reste une limite à optimiser séparément.
## Sodium et émission
[Sodium 0.9.2+mc26.3](https://modrinth.com/mod/sodium/version/bAZQdGpg) est fixé
pour Fabric 26.3 dans le pack client, avec le SHA-512 du JAR officiel. Il est
séparé du JAR Sanctuary et absent du serveur. Un lancement Vulkan seul ne
suffit pas à établir que tous les effets fonctionnent.
Le test ciblé reproduit l’absence de brillance : zéro pixel émissif, même
avec les minerais exposés et l’intensité à 100 %. Sodium vide la liste
vanilla `visibleSections`, utilisée par les émetteurs et les surfaces PBR.
`SurfaceSections` utilise avec Sodium la disponibilité native des sections,
la caméra, l’arbre d’occlusion de Sodium (`isBoxVisible`) et les chunks déjà
chargés dans la portée locale de l’effet. Les sections cachées ne deviennent
pas des meshes PBR/émissifs simplement parce qu’elles sont dans le frustum.
L’adaptateur optionnel est compilé contre la version exacte, sans embarquer
Sodium dans le JAR Sanctuary.
Le parcours vanilla existant est conservé pour le mod autonome. L’occlusion
des fragments reste fondée sur la profondeur réelle du terrain.
Un essai natif sans Sodium vérifie aussi les profils et le SSR en Moyen.
Après correction, le contrôle Vulkan avec Sodium compte 14 649 pixels
émissifs et valide les cœurs indépendants, les halos OFF/zéro, l’intensité,
le rayon, les minerais couverts, les modifications en direct, le rechargement,
les torches, glowstone, lampes, lave et soleil. Captures et journaux dans
`build/volume256/`.
## Mesures sur le monde Grand
Essais Windows sur Ryzen 7 3700X / RX 9070 XT, Vulkan 1.4.315,
pilote AMD 25.10.30, Java 25.0.1, `-Xmx8G`, 1920 × 1080, VSync désactivée,
FPS non limités. Monde neuf `sanctuary:island231_large`, graine 0, génération
231 existante ; distance d’affichage 32 chunks, simulation 5. Plus de 3 000
chunks chargés avant mesure, puis 400 ticks de préparation. Terrain à
(-40, 264, 40), vue vide depuis (560, 264, 0), trajet de 240 blocs en
spectateur, positions espacées de 6 blocs toutes les quatre ticks.
Chaque phase dure environ huit secondes. Les quinze phases sont exécutées
dans l’ordre sans effets, ombres, rayons, Moyen, Personnalisé, avec les mêmes
positions et le même soleil immobile. Les FPS ci-dessous sont calculés sur
les intervalles entre images effectivement rendues ; les durées CPU de
rendu sont consignées séparément. Le p95 est le temps sous lequel se
trouvent 95 % des images. Ce ne sont pas des mesures GPU isolées.
| Profil / vue | beta.255 FPS moyens | beta.256 FPS moyens | p95 avant → après |
| --- | ---: | ---: | ---: |
| Moyen, terrain immobile | 368,0 | 492,2 | 3,58 → 2,51 ms |
| Moyen, déplacement | 135,5 | 321,0 | 11,65 → 6,04 ms |
| Moyen, vide | 1 385,8 | 1 061,4 | 0,82 → 1,17 ms |
| Personnalisé, terrain immobile | 133,0 | 296,9 | 10,21 → 5,30 ms |
| Personnalisé, déplacement | 72,3 | 113,0 | 30,22 → 15,27 ms |
| Personnalisé, vide | 1 317,6 | 1 012,3 | 0,85 → 1,23 ms |
La référence est beta.255 sans Sodium, le candidat beta.256 avec Sodium et
les effets rétablis. La comparaison mesure l’ensemble de cette livraison,
y compris le recensement devenu paresseux, pas une optimisation isolée du
shader ni le seul gain de Sodium. Sur cette scène le déplacement progresse
de 137 % en Moyen et de 56 % en Personnalisé. La vue vide augmente de
0,35–0,38 ms au p95 avec le nouvel ensemble, tout en dépassant 1 000 FPS.
La référence avec Sodium avant correction contient des effets manquants :
ses FPS ne constituent pas une comparaison à rendu équivalent.
Le Personnalisé de mesure est un cas lourd reproductible : budget Custom,
ombres à 128 blocs, PBR/SSR à 64, SSGI et rayons à 35 %, portée solaire 32,
densité 96. Il ne reproduit pas tous les réglages inconnus du Custom du
créateur. Moyen comprend ombres, SSR, PBR, SSGI, bloom, minerais, lumières
colorées et rayons à 10 %. Les mêmes catégories d’effets sont activées dans
la référence, avec leur implémentation beta.255.
Journaux, quinze mesures JSON, captures et JFR :
`build/volume256/baseline.json`, `candidate-budget.json`,
`baseline.jfr`, `candidate-budget.jfr` et leurs résumés. Le contrôle du
nouveau monde confirme qu’une partie normale n’active pas `TerrainCensus`.
La trace de déplacement ne contient plus ses parcours permanents ; les
reconstructions des ombres/PBR/lumières et le travail Minecraft demeurent.
## Limites et vérification
Une passe courte sur terrain préchargé n’est pas un minimum garanti en jeu.
Le p99 du trajet vaut 9,36 ms en Moyen et 21,69 ms en Personnalisé. Deux images
de Moyen et une du Custom dépassent 50 ms ; maxima de 106 et 95 ms. La trace
Custom contient une pause G1 de 71,7 ms. La génération en déplacement, les
entités, d’autres scènes et une distance de simulation supérieure peuvent
changer le résultat. Le Custom mesuré n’atteint pas 144 FPS moyens et Moyen
ne tient pas 240 Hz à chaque image. Les chutes à 49 FPS de la session initiale
ne sont donc pas considérées comme entièrement éliminées.
Le lancement de test force le mode fenêtré, puis redimensionne à 1080p une
fois le monde chargé. Il évite le crash de lancement signalé ; le plein
écran exclusif n’est pas validé ni annoncé comme corrigé dans le jeu normal.
`./gradlew check build assemblePack -PsanctuaryFocusedTests=census,menus
-PsanctuaryAtlasOnly=true` réussit, avec six GameTests requis et les contrôles
purs. Compilation des contrôles client avec Java 25 également réussie.
La suite native complète avec les nouveaux budgets CPU passe : ombres et
feuilles, temps réel, contours, minerais/bloom, flare, SSGI à sept distances,
PBR, modifications, rechargement, redimensionnement et aller-retour Nether.
Le contrôle ciblé des rayons à 1080p valide une hausse lumineuse p95 de 4
niveaux à 10 %, zéro pixel assombri et une erreur moyenne de 0,012 niveau
par rapport à la densité doublée. Le SSR natif passe sur eau, verre et
surfaces opaques, avec mouvement, OFF/zéro, rechargement et resize.
Menus, import/export, profils et alpha réel des feuilles passent avec Sodium.
Un premier relevé à la frontière de cellule a échoué (57 108 pixels sur
65 664 dépassaient le seuil). La production inchangée passe ensuite la
suite complète avec captures avant/après, puis un contrôle ciblé de trois
passages, dont deux conservent le timing de lecture initial. Le seuil de
différence n’a pas été relâché. La cause du premier relevé isolé reste
indéterminée ; les journaux d’échec sont conservés.
L’oracle de rafraîchissement PBR utilisait lui aussi la liste vanilla vide
avec Sodium. Il invalide maintenant les sections de `SurfaceSections` et
exige au moins une section, puis une publication effective des remplacements.
Le contrôle corrigé passe avec sept sections, puis valide les directions
opposées, les murs, la lumière portée, la stabilité du cache et la grotte
en mouvement sans points blancs isolés. Les trois angles rasants passent.
Les profils et le SSR en Moyen repassent aussi sans Sodium sur les sources
finales. Les 3 983 fichiers de production Sanctuary testés sur Windows
correspondent par SHA-256 aux sources locales de livraison.
## Artefacts locaux
[Pack beta.256](../build/Sanctuary-beta.256.mrpack), Minecraft 26.3 / Fabric
0.19.5, Sodium requis côté client uniquement. Le JAR Sanctuary embarqué
correspond exactement au JAR du build et ne contient aucune classe Sodium.
Canal public et instance Prism personnelle non mis à jour.
| Artefact | SHA-256 |
| --- | --- |
| `sanctuary-beta.256.jar` | `9c4f10f9a764c391fe8fa7679569d13c17e01e8c44eef92d115047544468120d` |
| `Sanctuary-beta.256.mrpack` | `8c01aef32ce508e67016743a171d13dfa9633cddecd87c7a7424aa30816b585c` |
[Reçu final et mesures](../build/volume256/validation.json), journaux et
captures dans `build/volume256/`. Dégradés du ciel réservés à la prochaine passe.
+140
View File
@@ -0,0 +1,140 @@
# APT-236 — aptitudes par thème et activation personnelle
La règle de désactivation décrite ici correspond à la livraison beta.236.
[Beta.237](aptitudes-beta237.md) la remplace : seuls World Map et Mob Names
restent désactivables, les autres aptitudes sont acquises définitivement.
Le chantier Pause beta.235 est intégré dans cette nouvelle livraison.
Branche `codex/progression-aptitudes`, base beta.234 (`f9c2ce8`). Le travail
parallèle du menu Pause occupe beta.235 ; ce lot réserve beta.236.
## Contrat avant implémentation
Progression regroupe les aptitudes en cinq thèmes : Survie (Swimming, Rest,
Food Knowledge), Inventaire et fabrication (Inventory Sorting, Catalogue),
Familiers (Active Bond, Familiar Study), Exploration (World Map, Mob Names,
Zoom), Arts et métiers (Enchantement, Alchimie). Le minage et la forgerie
restent hors de ce ticket.
Enchantement et Alchimie coûtent chacun 4 niveaux, comme les aptitudes actuelles.
L'usage personnel de la table d'enchantement et de l'alambic est verrouillé au
départ dans les mondes avec progression Sanctuary. Les menus natifs sont
refusés côté serveur tant que l'aptitude correspondante n'est pas acquise et
active, y compris via les blocs fonctionnels portés sur la tête. Les enclumes,
la table de forge, les équipements enchantés et les potions déjà obtenues
restent inchangés. Les alambics partagés et leur automatisation continuent
leur fonctionnement autonome : ce lot contrôle l'accès personnel au poste,
sans attribuer de propriétaire à un bloc ni arrêter une préparation lancée.
Les personnages existants avec progression Sanctuary doivent aussi acquérir
ces deux nouvelles aptitudes ; aucune acquisition gratuite n'est ajoutée.
Une aptitude achetée peut être activée ou désactivée sans coût et sans perdre
son achat. Food Knowledge reste permanent après apprentissage. Les compétences,
les prix et les découvertes ne changent pas. Les effets de gameplay sont
contrôlés côté serveur ; les aides d'interface utilisent le choix serveur.
Active Bond contrôle l'usage des pouvoirs de travail, pas les techniques de
combat, les pouvoirs passifs ou l'équipement du familier. Une action déjà
lancée se termine normalement ; les nouvelles activations sont refusées.
## Persistance et compatibilité
L'attachement additif `sanctuary:aptitude_preferences` conserve un entier de
0 à 2047 : un bit par aptitude désactivée, dans l'ordre immuable names, atlas,
sorting, swimming, resting, active_bond, familiar_study, catalogue, zoom,
enchanting, alchemy.
L'absence de cet attachement équivaut à zéro : toutes les aptitudes acquises
gardent leur comportement actuel. Il est copié à la mort et conservé au New
Game+. Deux attachements booléens additifs `sanctuary:enchanting_learned` et
`sanctuary:alchemy_learned` conservent leurs nouveaux achats ; leur absence
équivaut à false et ils sont copiés à la mort et conservés au New Game+.
Aucun schéma ou contenu des achats existants n'est réécrit. Ce contrat
est limité à cet attachement ; aucun monde personnel n'est ouvert ou modifié.
Une requête transmet la cible, le choix et le masque attendu ; le serveur
vérifie l'achat, la session, l'état du joueur et le masque courant. Un choix
inconnu, Food Knowledge, une aptitude non acquise ou une requête périmée est
refusé sans XP dépensée. Un état distinct est renvoyé avant le snapshot de
progression. La capacité réseau `aptitude_preferences_v1` exige les versions
compatibles sur client et serveur sans changer les paquets existants.
Désactiver Swimming interrompt le sprint aquatique ; désactiver Rest arrête
la récupération. World Map masque son entrée et refuse les nouvelles lectures
personnelles ; les relevés continuent d'être conservés et l'outil opérateur
reste disponible. Catalogue masque l'aide personnelle JEI et la consultation
complète de Discovery, sans toucher aux recettes ni aux découvertes.
Un ancien mod ignore ces trois nouveaux attachements et retrouve son ancien
accès aux postes. Il peut perdre les attachements inconnus en sauvegardant :
conserver une sauvegarde avant un retour arrière. Le schéma 9 de progression
et les preuves des aptitudes antérieures restent identiques.
## Vérifications
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=aptitudes236,progression,movement,sorting,recipes,foodknowledge \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryAptitudes236ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
```
Première passe serveur : `check build assemblePack` réussi en 3 min 13 s,
**33 GameTests requis réussis**, dont cinq scénarios APT-236. Contrôles purs
du dépôt réussis. Le banc Atlas existant évite le conflit de génération du
chemin par défaut déjà documenté en beta.232. Monde de développement neuf.
Log : `build/aptitudes236-check-build-pack.log`.
- Onze choix indépendants, refus des requêtes inconnues, non acquises,
périmées et de Food Knowledge ; conservation exacte de l'XP et des preuves
d'achat. Achat à 3 niveaux refusé, à 4 accepté une seule fois.
- Aller-retour des nouveaux attachements dans le NBT joueur natif, copie à
la mort, conservation lors de la remise à zéro des compétences du cycle.
- Swimming arrête le sprint aquatique et interdit les flags de mouvement
directs ; Rest arrête la récupération ; tri falsifié et lectures Atlas
personnelles refusés. Réactivation du tri contrôlée avec la même requête.
Active Bond refuse une nouvelle activation avant toute planification.
- Table d'enchantement native refusée avant acquisition, ouverte ensuite ;
**une épée diamant est réellement enchantée**. Désactiver ferme le menu,
refuse sa réutilisation et son bouton d'enchantement sans dépense d'XP.
Alambic natif refusé puis ouvert, fermeture et invalidation à la désactivation,
ingrédients partagés conservés et réouverture après réactivation.
Parcours client final réussi en **52 s**, Minecraft 26.3, Vulkan confirmé par
MoltenVK 1.4.2 / Apple M1. Log : `build/aptitudes236-client-vulkan.log`, marqueur
`APTITUDES236_PASS` ; douze captures dans `build/aptitudes236-screenshots/`.
- FR/EN, GUI 2/3/4, fenêtre 1280 × 960 : cinq thèmes dans l'ordre, les douze
aptitudes présentes, Food Knowledge permanent et sans interrupteur.
- Achat des deux arts depuis les véritables boutons de Progression : huit
niveaux au total. **66 paires désactivation/réactivation** via les widgets
et le réseau, état serveur contrôlé, XP et progression finale identiques.
- Libellés d'état entièrement contenus dans les boutons ; défilement et
position conservée au redimensionnement 1344 × 1008 ; Terminé revient à
Pause. Une première passe a trouvé le libellé permanent trop large en GUI 4 :
retour à la ligne ajouté. Les interrupteurs affichent un état court et
expliquent leur action gratuite dans l'infobulle.
Les essais concernent les sauvegardes de développement. Pas de validation
Windows ou multijoueur distant. L'automatisation des alambics et les pouvoirs
déjà lancés restent les limites explicites du contrat ; la forgerie attend
son prochain ticket.
## Livraison locale
Build final `check build assemblePack` réussi en **2 min 24 s**, 136 tâches,
33/33 GameTests requis et contrôles purs réussis après les derniers réglages
d’interface. Log : `build/aptitudes236-check-build-pack-final.log`.
Versions du mod, du pack et du manifeste alignées sur **beta.236**.
[MRpack local](../build/Sanctuary-beta.236.mrpack) vérifié : 12797046 octets,
intégrité ZIP, Minecraft 26.3 / Fabric Loader 0.19.5, un seul JAR Sanctuary
identique au build final, sans classe de test ni monde ni module de laboratoire.
Les deux PNG fournis sont identiques octet pour octet aux originaux dans le JAR.
Reçu : `build/aptitudes236-artifact.json`.
- SHA-256 JAR : `21a4411ebed223a1d9f9d204dc9d7300af0d5b946416e39bb138f5c575bf5847`.
- SHA-256 MRpack : `9d3eca559805d451e8c653cca1d59186ae46e3c6964147b9fc072b9da832f921`.
La branche reste indépendante du chantier Pause beta.235 ; leur intégration
reste à effectuer. Aucune publication distante, mise à jour du canal packwiz
ou synchronisation d’instance personnelle.
+99
View File
@@ -0,0 +1,99 @@
# APT-237 — achats permanents et intégration du menu Pause
Branche `codex/progression-aptitudes`. Intégration des commits beta.235
`a7bdfe6` et `00d175c`, avec les aptitudes beta.236 `1c8e643`.
## Contrat avant implémentation
Seuls World Map et Mob Names disposent d'un interrupteur gratuit après achat.
Swimming, Rest, Inventory Sorting, Catalogue, Active Bond, Familiar Study,
Zoom, Enchantement, Alchimie et Food Knowledge restent acquis et utilisables
sans désactivation. Les prix et les conditions d'achat restent identiques.
Enchantement et Alchimie exigent toujours leur achat avant l'accès personnel
au poste natif. Les achats, l'XP et les preuves existantes sont conservés.
Le menu Pause reprend la disposition beta.235 : deux colonnes Aventure et
Communauté avant World Map, une colonne à gauche avec la carte. Le choix
personnel de masquer World Map suit aussi cette disposition.
## Contrat de migration des préférences beta.236
L'identifiant `sanctuary:aptitude_preferences`, son codec entier 0–2047 et
l'ordre historique des onze bits restent stables. Les bits 0 (Mob Names) et
1 (World Map) restent effectifs. Les bits 2 à 10 deviennent inertes et sont
retirés de cet attachement lors de sa prochaine synchronisation serveur.
Ainsi un ancien masque 2047 devient 3 : les deux aides restent masquées et
les neuf autres aptitudes achetées retrouvent automatiquement leur usage.
Le chargement accepte encore les anciennes valeurs ; les lectures et effets
ignorent immédiatement leurs bits désormais inertes. Aucune preuve d'achat,
aucun format de progression, aucun monde ou terrain n'est réécrit.
Les requêtes de désactivation des aptitudes permanentes sont refusées même
si elles proviennent d'un ancien client. Les paquets v1 restent inchangés ;
une capacité additive `sanctuary:aptitude_permanent_v1` impose un client avec
cette règle côté serveur. Les nouvelles interfaces sont traduites FR/EN.
Le retour à une ancienne version nécessite comme auparavant une sauvegarde,
car cette version ne peut restaurer les anciennes désactivations effacées.
## Vérification et livraison
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=aptitudes236,menus,progression,movement,sorting,recipes,foodknowledge \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryAptitudes236ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
```
Les suites APT-236 sont actualisées pour vérifier la nouvelle règle et la
migration ; leurs noms Gradle restent stables. `check build assemblePack`
réussi en **2 min 34 s**, **37 GameTests requis réussis**, dont six scénarios
aptitudes. Contrôles purs du dépôt réussis. Le banc Atlas évite le chemin de
génération par défaut déjà documenté en beta.232. Log :
`build/aptitudes237-check-build-pack.log`.
- Les deux choix personnels sont gratuits ; refus des requêtes périmées,
inconnues, non acquises et des désactivations d'aptitudes permanentes.
Prix des nouveaux achats vérifié à trois/quatre niveaux, achat unique.
- Ancien masque 2047 rechargé dans le NBT natif, interprété en 3 puis
normalisé sans changement d'XP ni de preuve d'achat. Conservation des
choix et des achats à la mort et au New Game+.
- Avec d'anciens bits désactivés : sprint aquatique, Rest et tri restent
utilisables ; Active Bond suit son contrôle normal de familier équipé.
World Map conserve son contrôle d'accès et sa réactivation gratuite.
- Enchantement et Alchimie refusés avant achat, puis disponibles lors des
visites suivantes. Une épée diamant est réellement enchantée. Une requête
de désactivation refusée ne ferme ni n'invalide les menus acquis ; ingrédients
partagés de l'alambic conservés. Accès vanilla hors progression conservé.
Parcours client réussi en **32 s**, Minecraft 26.3, Vulkan / MoltenVK 1.4.2
sur Apple M1. FR/EN, GUI 2/3/4, fenêtre 1280 × 960 : cinq thèmes, douze
aptitudes, dix achats permanents, **12 paires désactivation/réactivation**
réelles des deux aides. Deux achats des arts via les boutons natifs de
Progression, huit niveaux au total. Libellés contenus dans leurs boutons,
XP et preuves conservées, défilement conservé au redimensionnement et retour
Terminé vers Pause.
La même suite réutilise les assertions Pause du collègue : disposition,
ordre clavier, marges, titres et libellés de navigation vérifiés après achat
de World Map, avec son choix personnel désactivé puis activé. Les deux
colonnes et la colonne près de la carte sont conservées. Les captures de
Pause portent sur la disposition ; les relevés et fils commencent à charger.
Log `build/aptitudes237-client-vulkan.log`, marqueur `APTITUDES237_PASS` ;
**24 captures** dans `build/aptitudes237-screenshots/`. Inspection des captures
Arts FR GUI 3, Pause sans carte FR GUI 4 et Pause avec carte EN GUI 3.
Versions alignées sur **beta.237**. [MRpack local](../build/Sanctuary-beta.237.mrpack)
vérifié : 12797901 octets, intégrité ZIP, Minecraft 26.3 / Fabric Loader 0.19.5,
un seul JAR Sanctuary identique au build final, sans tests ni monde ni module
de laboratoire. Les deux icônes fournies restent identiques octet pour octet
aux originaux. Reçu `build/aptitudes237-artifact.json`.
- SHA-256 JAR : `7ac741c6a15b8ab05a909cf0132c9d605b863723c0742a92d8b60c741fd760a6`.
- SHA-256 MRpack : `7635e9e7a425cbd7a7f6d020f9bb2bcced8cc1287967093346295e4b4b2250e9`.
Les anciens artefacts beta.236 sont conservés. Tests dans des mondes de
développement neufs ; Windows et multijoueur distant non testés. Les
alambics autonomes et pouvoirs déjà lancés gardent le contrat beta.236.
La forgerie, les enclumes et la table de forge attendent leur prochain ticket.
Aucune publication, avancée du canal ni synchronisation d'installation personnelle.
+152
View File
@@ -1,5 +1,157 @@
# Backlog Sanctuary
## HELLO-264 — Menu central et sol commun — livré localement
Sur `codex/hello-world-menu`, socle beta.263. Colonne centrale de 182 pixels,
choix regroupés par section, noms au-dessus des aperçus et familiers bébé
miniatures alignés avec les pieds du personnage. Espèce, rareté et description
au survol ; Retour séparé et Entrer dans Sanctuary dans le pied de page natif.
Build, 13 GameTests serveur et parcours Vulkan FR/EN GUI 2/3/4 réussis ;
44 modèles bébé, création, reconnexion et code d'accès vérifiés.
Pack local beta.264 exporté et contrôlé.
[Disposition et vérifications](hello-layout-beta264.md).
## HELLO-263 — Aperçus des bébés familiers — livré localement
Sur `codex/hello-world-menu`, socle beta.262. Modèles et textures bébé, échelles
d'âge natives et cadrage adapté. Les 44 aperçus sont comparés aux proxies
réels après `Mob.setBaby(true)` ; parcours Vulkan FR/EN GUI 2/3/4 réussi.
Build, 13 GameTests serveur et pack local vérifiés.
[Correction et vérifications](hello-baby-familiars-beta263.md).
## HELLO-262 — Personnage, couleurs et familier — livré localement
Branche `codex/hello-world-menu`, socle beta.261. Menu à trois colonnes, blocs
centraux de même hauteur et colonnes latérales de même largeur. Palette serveur
paginée par neuf avec la texture native de hotbar et les flèches du pseudo.
Fond texturé uniforme ; personnage et familier en 3D suivent la souris.
Biographie et nom au centre. Les règles et sauvegardes serveur sont conservées.
Build, 13 GameTests serveur et parcours Vulkan FR/EN GUI 2/3/4 réussis ;
44 modèles de départ, admission, reconnexion et code d’accès vérifiés.
[Contrat et vérifications](hello-world-menu-beta262.md).
## SHADER-258 — Ombres des falaises et musique différée — livré localement
Branche `codex/zenith-shadows-beta258`, socle beta.257. Comparaison de profondeur
au vrai centre des texels, correction du cas rasant autour du zénith et ombres
des surplombs conservées ; budgets de carte inchangés. Musique du menu différée
de trois secondes après chargement, absente dans Hello World et l’introduction.
Build, six GameTests serveur et contrôles Windows Vulkan/8 Gio réussis : falaise
à quinze positions solaires, horloge civile 10 h–14 h et midi solaire ±30 s,
ancres caméra, feuilles et véritable flux audio. Particules mobiles exclues
uniquement des captures de mesure ; tolérances d’image conservées.
Packs normal/Test assemblés et vérifiés ; aucune publication ni mise à jour Prism.
[Contrat, vérifications et limites](zenith-shadows-beta258.md).
## MODS-257 — Mods client séparés et sun shafts à 20 % — livré localement
Branche `codex/external-mods-beta257`, socle beta.256. JEI Sanctuary et Sound
Physics sortis du JAR principal, facultatifs avant lancement ; Sodium déjà
séparé devient également facultatif dans packwiz. Pont JEI, progression serveur
et fabrication manuelle conservés. Menus et message d’absence FR/EN.
Profils et valeurs neuves des rayons à 20 %, SSR en Moyen conservé.
Build, 8 GameTests serveur sans JEI/Sound Physics et quatre combinaisons client
Vulkan/8 Gio réussis. Transfert réel JEI, options audio et rayons à 20 % vérifiés.
Packs normal/Test assemblés et contrôlés ; aucune publication ni mise à jour Prism.
[Contrat et vérifications](external-mods-beta257.md).
## SHADER-256 — Volume solaire, Sodium et déplacement — livré localement
Branche `codex/ambiance-volume-performance-beta256`, socle beta.255.
Rayons à pas fin, intensité neuve de 10 %, portée indépendante et densité
dans Atmosphère, FR/EN et import/export. Carte d’ombres réutilisée,
reconstructions CPU réparties, recensement serveur activé à la demande.
Sodium 0.9.2+mc26.3 ajouté au pack client ; émissions et PBR corrigés pour
ses sections visibles. SSR conservé en Moyen et aucun preset à 256 blocs.
Mesure native Grand/32 chunks/1080p/8 Gio : Moyen 135 → 321 FPS en mouvement,
Personnalisé lourd 72 → 113, avec des pauses restantes. Build, six GameTests,
effets et pack vérifiés ; pas de minimum 144/240 FPS annoncé.
[Contrat, méthode, mesures et limites](ambiance-volume-performance-beta256.md).
Ciel différé ; lancement de test fenêtré, plein écran exclusif non validé.
## SHADER-255 — Feuilles, menus et rayons Ambiance — livré localement
Branche `codex/ambiance-leaves-menus-beta255`, socle beta.254. Alpha réel des
feuilles, ombres visibles en Faible/Moyen, sous-menus FR/EN et import/export
des profils complets, préférences d'eau par défaut comprises. Sun shafts plus
visibles en Moyen avec le même budget ; rendu retenu par le créateur.
Build, quatre GameTests serveur, suites natives Vulkan RX 9070 XT et pack
beta.255 vérifiés. Surcoût mesuré sur canopée dense : Moyen 0,035–0,047 ms,
Faible 0,035–0,061 ms, hors FPS de jeu.
[Contrat et validation](ambiance-leaves-menus-beta255.md).
Dégradés du ciel : passe suivante à la demande du créateur.
## SHADER-254 — Profils et optimisation Vulkan — livré localement
Branche `codex/vulkan-shader-optimization`, base beta.253. Shader **Ambiance**,
cinq profils FR/EN, SSR inclus dans Moyen, carte d'ombres commune conservée
entre les mises à jour,
budgets SSGI/SSR/volume et émission limitée en portée. Choix existants conservés,
configuration neuve préparée en Moyen avec activation volontaire.
[Contrat, profils, mesures et limites](shader-vulkan-optimization.md).
Build, quatre GameTests serveur, suites natives Vulkan Windows RX 9070 XT
et MRpack beta.254 vérifiés. Moyen avec SSR : 1,56 ms statique, 1,52 ms soleil
mobile contre 2,19/2,44 ms pour l'ancien ensemble dans le test à 1080p.
Temps de rendu/soumission, hors comparaison de FPS de jeu. Feuillage
procédural conservé ; canal public et instance personnelle inchangés.
## WAY-241 — Ascenseurs séparés, recherche et tarif administrable — livré localement
Branche `codex/waystones-menu-beta241`, base beta.240. Colonnes d’ascenseur
exclues du réseau horizontal, recherche par nom, boutons centrés sans Y,
distance et prix uniquement au survol, R pour renommer. Commande de tarif
persistant côté serveur, défaut 1 point / 16 blocs ; FR/EN et jump/sneak.
[Contrat et essais](waystones-menu-beta241.md). Avec 10 niveaux sur Grand
graine 0 : 29 trajets de quartier, 10 longs ou 160 déplacements d’étage.
Build, 9 GameTests ciblés, deux tests Grand, client Vulkan FR/EN GUI 2/3/4
et MRpack beta.241 vérifiés.
## WAY-240 — Waystones : 1 XP / 16 blocs — livré localement
Branche `codex/waystones-tarif-beta240`. Nouveau tarif horizontal et vertical,
arrondi au supérieur conservé, rappel FR/EN alimenté par la règle serveur.
[Comparaison et vérifications](waystones-tarif-beta240.md) sur les mêmes maisons
du Grand neuf graine 0 : 29 trajets de quartier, 10 longs et 46 mixtes pour
160 points. Build, 7 GameTests ciblés, deux tests natifs Grand, client Vulkan
FR/EN GUI 2/3/4 et MRpack beta.240 vérifiés.
## WAY-239 — maisons et étages par Waystones — livré localement
Branche `codex/waystones-beta239`, base consolidée avec Forgerie beta.238 et
Pause restauré. Texture originale 26.2, nom éditable, XP en points selon la
distance 3D : 1 / 8 blocs arrondi au supérieur. Découverte horizontale
personnelle ; Saut / S'accroupir pour les étages inconnus. Carte M et marqueurs
dans les deux cartes. [Contrat, données et mesures](waystones-beta239.md).
Grand natif graine 0, huit maisons et deux étages : 15 visites de quartier,
4 longs trajets, 26 mixtes pour 10 niveaux sans regain d'XP. Tests serveur,
Vulkan et assemblage réussis, MRpack beta.239 vérifié ; aucune installation
personnelle.
## PAUSE-236 — Atlas plein écran derrière Pause — essai annulé
Le créateur annule uniquement la carte derrière le menu. Le commit de
disposition `5ea7950` est inversé ; affichage beta.235 restauré, Story et
raccourci M (`94fefc0`) conservés. [Historique et annulation](pause-map-layer-beta236.md).
L'ancien pack local est retiré des artefacts actifs. Cette annulation ne publie
aucune nouvelle version et ne modifie pas la branche des aptitudes/Forgerie.
## APT-238 — Forgerie — livré localement
Branche `codex/forgerie-beta238`, base beta.237. Aptitude permanente des Arts
et métiers ; quatre niveaux pour les trois variantes d'enclume et la table
de forge, y compris les postes portés. [Contrat et persistance](forgerie-beta238.md).
Réparations, noms, améliorations et ornements gardent leurs règles natives.
Build, 21 GameTests requis, Vulkan FR/EN GUI 2/3/4 et MRpack vérifiés.
## APT-237 — achats permanents et intégration Pause — livré localement
Branche `codex/progression-aptitudes`, travaux beta.235 et beta.236 réunis.
Cinq thèmes, douze aptitudes ; seuls World Map et Mob Names ont un interrupteur.
Enchantement et Alchimie restent à acheter avant l'accès personnel au poste.
[Contrat et migration](aptitudes-beta237.md) des préférences antérieures,
avec conservation des achats et de l'XP. Build, 37 GameTests requis,
Vulkan FR/EN GUI 2/3/4 et MRpack local vérifiés. La forgerie attend ses précisions.
## PAUSE-235 — groupes côte à côte avant Atlas — essai livré localement
Branche `codex/pause-columns-beta235`, base beta.234. Avant Atlas : Aventure à
+108
View File
@@ -0,0 +1,108 @@
# MODS-257 — Mods client séparés et rayons à 20 %
Branche `codex/external-mods-beta257`, base `beta.256`, livraison `beta.257`.
Minecraft 26.3 finale, Fabric Loader 0.19.5, API 0.160.5+26.3, Java 25.
## Livré localement
JEI et Sound Physics Remastered sortent du JAR Sanctuary ; Sodium était déjà
séparé depuis beta.256. Ils restent fournis par défaut dans le pack client,
mais chacun peut être désactivé avant le lancement. Aucun déchargement à chaud.
Fabric API reste nécessaire au fonctionnement de Sanctuary.
JEI conserve le portage Sanctuary, révision `30.32.0-sanctuary.4` : ce n'est pas
un remplacement arbitraire par un binaire upstream. Avec lui, le catalogue,
les recettes apprises, les restrictions, les favoris et le transfert depuis
l'inventaire étendu gardent leur pont. Sans lui, le bouton du catalogue est
indisponible avec un texte FR/EN. La découverte, les connaissances et leur achat,
les notifications, les règles serveur et la fabrication manuelle fonctionnent
indépendamment. Aucun changement d'identifiant, génération ou format de sauvegarde.
Sound Physics officiel `1.5.1+26.3` est épinglé par version et SHA-512 dans
`packwiz/mods/sound-physics-remastered.pw.toml`. Son écran Sanctuary n'apparaît
que si le mod est présent. Le moteur reste désactivé sur une configuration
neuve ; les réglages existants sont conservés. Le mixin de défaut est facultatif.
Sodium officiel reste `0.9.2+mc26.3`, SHA-512 fixé, avec le pont de visibilité
beta.256 pour le bloom et le PBR.
Les bibliothèques Java restent chez leur consommateur : MariaDB Connector/J
avec Sanctuary, Baked Substring Index et Suffix Tree avec JEI. Ce sont des
bibliothèques d'exécution, pas des mods de gameplay indépendants à désactiver.
Aucun autre mod Fabric tiers n'est imbriqué dans Sanctuary.
Les profils et valeurs initiales des sun shafts passent à **20 %**, avec la
qualité et les portées beta.256. Un réglage personnalisé explicitement enregistré
reste prioritaire ; sélectionner à nouveau un profil applique 20 %.
SSR conservé en Moyen ; aucune portée 256 n'est sélectionnée automatiquement.
## Distribution
Le pack local contient Sanctuary et JEI comme deux JAR séparés. JEI peut être
décoché dans Prism ; le fork local non publié est un fichier override du MRpack.
Sodium et Sound Physics ont des entrées packwiz facultatives, activées par défaut.
Le canal publié utilisera aussi une entrée facultative `jei-sanctuary.pw.toml`,
avec URL immuable et SHA-256 du fork. La publication doit joindre les **deux**
JAR puis seulement avancer le canal ; `scripts/publish_pack.py` prépare ce contrat.
Une installation pilotée par packwiz doit mémoriser le choix dans les options
de l'[installateur packwiz](https://packwiz.infra.link/tutorials/installing/packwiz-installer/) pour ne pas retélécharger un mod supprimé à la main. Une
instance locale importée du MRpack permet de décocher les JAR dans Prism.
Aucun canal public ni instance personnelle n'est mis à jour par ce ticket.
## Vérifications effectuées
- `./gradlew check build assemblePack assembleTestPack` avec
`-PsanctuaryFocusedTests=recipes -PsanctuaryAtlasOnly=true`,
`-PsanctuaryJeiRuntime=false -PsanctuarySoundPhysicsRuntime=false` ;
compilation des contrôles client séparée. Le harnais serveur historique
`unified` sans le fixture plat suppose encore un générateur remplacé dans
le socle courant ; ces flags évitent son cast invalide, sans toucher la génération.
- GameTests de recettes sans JEI ni Sound Physics à l'exécution.
- Client Windows Vulkan, fenêtre, `-Xmx8G` : trois mods présents, tous absents,
JEI seul et Sound Physics seul ; création de mondes de test neufs, recettes,
progression, achat, autorisations et menus FR/EN.
- Catalogue JEI et transfert réel, options Sound Physics et persistance.
- Rayons à 20 % sur scène native, silhouettes, absence d'assombrissement parasite.
- Archives locale/Test et préparation de publication hors ligne : versions,
dépendances, notices, absence des trois mods dans le JAR Sanctuary.
Build complet et assemblages normal/Test réussis ; **8 GameTests serveur requis**
passent sans JEI ni Sound Physics. Les contrôles client compilent avec Java 25.
La suite Windows Vulkan utilise la RX 9070 XT, une fenêtre et `-Xmx8G` :
| JEI | Sound Physics | Sodium | Monde, recettes, achat, permissions et menus |
| --- | --- | --- | --- |
| Absent | Absent | Absent | Réussi |
| Présent | Absent | Absent | Réussi |
| Absent | Présent | Absent | Réussi |
| Présent | Présent | Présent | Réussi |
Avec les trois mods, les contrôles ciblés valident les recettes apprises/cachées,
l'ouverture réelle du catalogue et le paquet de transfert JEI : quatre planches
consommées dans la sixième ligne, une planche dans chaque case de fabrication.
Sound Physics valide défaut neuf OFF, préférence existante ON conservée,
menus FR/EN en GUI 2/4, activation, sauvegarde/relecture et validation numérique.
À 1080p, Moyen à 20 % donne une hausse lumineuse p95 de **7 niveaux**, aucun pixel
assombri et une erreur moyenne de **0,02574 niveau** par rapport aux 192 segments
(densité doublée). Erreur p95 globale et aux silhouettes : zéro. La suite valide
également le toit fermé, les feuilles, le déplacement/retour caméra, la portée,
OFF/zéro/nuit, rechargement, redimensionnement et transitions météo.
[Capture native à 20 %](../build/external257/medium20.png).
Les archives MRpack sont contrôlées octet par octet contre les deux JAR construits.
Le JAR Sanctuary ne conserve que Connector/J comme dépendance imbriquée ; JEI
conserve ses deux bibliothèques. Métadonnées de publication préparées et vérifiées
sans envoi réseau ni avancement du canal. **4 161 fichiers** de sources, ressources,
contrôles et construction correspondent au miroir Windows. La tâche de test
planifiée a été supprimée après succès.
[Reçu, empreintes et journaux](../build/external257/validation.json).
Pack normal : `b2ded25cdb17b56b65e6df556ed855571b75671e4dfb02baae48dfe421b76498` (SHA-256).
Les scripts de préparation/publication sont compilés et le contrat à deux artefacts
est vérifié hors ligne ; la publication effective reste hors de ce ticket.
Le fixture serveur historique `unified` reste incompatible avec le socle actuel
sans les flags de laboratoire indiqués ci-dessus ; il n'a pas été corrigé ici.
Les mesures de FPS beta.256 restent celles de cette livraison à 10 % ; aucune
nouvelle mesure de gain de performances n'est promise par cette séparation.
+73
View File
@@ -0,0 +1,73 @@
# FAM-261 — Attaque et ordres sur Alt
Demande du 12 octobre 2026, branche `codex/familiar-alt-beta261`, Minecraft
**26.3**, livraison **beta.261**. Le créateur précise que le maintien d’Alt
doit ouvrir les ordres et que H doit être retiré des commandes du familier.
## Comportement
- **Alt gauche bref** : attaque rapide / suivi, avec le ciblage existant.
- **Alt gauche maintenu** : ouvre les ordres du familier.
- **G** : technique, avec son réglage existant.
L’appui bref est envoyé au relâchement. Le maintien ouvre le menu après environ
0,4 seconde et consomme le geste : aucune attaque rapide n’est envoyée avant
l’ouverture ou au relâchement. Un geste commencé dans un inventaire, pendant
la mort ou sans fenêtre active est ignoré jusqu’au relâchement.
Une seule entrée des contrôles réunit attaque et ordres. Elle conserve la clé
de réglage de l’attaque rapide beta.260, `sanctuary.familiar.quick_key`, avec
Alt gauche par défaut ; une personnalisation existante de ce réglage s’applique
aux deux gestes. L’ancien raccourci séparé des ordres n’est plus enregistré :
son ancienne valeur H dans les options n’active aucune commande du familier.
L’ancien marqueur F→H n’est plus utilisé. Le titre du menu et les aides ont
leurs libellés FR/EN.
La correction de réapparition beta.260 est conservée. Ce changement est côté
client ; règles serveur, œufs, progression et sauvegardes restent identiques.
Le labo beta.259 reste fermé.
## Vérifications et artefacts
Le parcours natif réussit en **46 secondes**, sur **Vulkan / MoltenVK 1.4.2,
Apple M1**, avec 8 Gio maximum. Il vérifie l’appui bref, le maintien sans attaque
rapide avant l’ouverture ou au relâchement, H bref et maintenu inactifs, G
inchangé, les deux gestes reconfigurés sur K, le blocage d’Alt tenu dans
l’inventaire puis relâché après fermeture, les infobulles FR/EN et une véritable
mort/réapparition suivie d’une attaque du loup.
Journal : `build/familiar261-client.log`, marqueur `FAMILIAR261_CLIENT_PASS`.
Capture du menu après réapparition :
`mods/sanctuary/build/run/clientGameTest/screenshots/0002_familiar261-orders-after-respawn.png`.
Le scénario utilise un nouveau monde plat, graine **261**, avec serveur intégré.
Les essais Windows et multijoueur distant restent ouverts.
```sh
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryFamiliar260ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
`check build assemblePack assembleTestPack` réussit en **2 min 43 s**, avec
`-PsanctuaryFocusedTests=eggaudit,familiar,familiar260,accessories
-PsanctuaryAtlasOnly=true` : **44 GameTests serveur requis réussis**.
Journal : `build/familiar261-build.log`.
Les deux MRpacks sont exportés et vérifiés : intégrité ZIP, version beta.261,
Minecraft 26.3, JAR embarqués identiques aux builds et classes identiques à la
compilation. Sur les **2 520 classes** du mod normal, seule `FamiliarClient`
change face à beta.260 ; les **88 classes** du module de labo restent identiques.
Les aides FR/EN de la carte sont également actualisées pour Alt.
Reçu et empreintes SHA-256 : `build/familiar261-validation.json`.
[Pack normal beta.261](../build/Sanctuary-beta.261.mrpack) ·
[Pack Test beta.261](../build/Sanctuary-Test-beta.261.mrpack).
Livraison locale ; la visite figée beta.259 reste fermée et conserve son binaire.
Le parcours natif de réapparition existant `Familiar260ClientChecks` est actualisé
pour le raccourci unique : appui bref, maintien sans attaque, H inactif,
reconfiguration des deux gestes, blocage dans l’inventaire, puis commandes et
attaque réelle après réapparition. Son sélecteur Gradle conserve son nom
`sanctuaryFamiliar260ClientTests`.
+167
View File
@@ -0,0 +1,167 @@
# FAM-LAB-259 — Arène des familiers
Demande du 12 octobre 2026, branche `codex/familiar-lab-beta259`, Minecraft
**26.3**, version **beta.259**. Premier incrément : essayer les familiers actuels
avant leur rework, choix confirmé par le créateur.
Les retours de cette séance sont traités dans [beta.260](familiar-respawn-beta260.md).
Avec beta.260, l’attaque rapide passe sur Alt ; H maintenu conserve les ordres.
Depuis [beta.261](familiar-alt-beta261.md), Alt bref et Alt maintenu réunissent
attaque et ordres ; G garde la technique.
La visite figée `review-02` garde son ancien binaire beta.259 et ne reçoit pas
les correctifs.
## Carte de test
Le module facultatif `sanctuary-test` construit une scène neuve de 49 × 41 blocs,
plancher Y=80, dans `build/familiars-259/<session>/server/familiars-flat-259`.
Graine **259**, disposition **1**, monde plat de développement. La scène est
indépendante du générateur Sanctuary ; aucun ancien chunk n'est remplacé.
Le constructeur exige le dossier dédié, le générateur plat et une option JVM
explicite. Une préparation incomplète est refusée et conservée.
- **Cinq coffres**, un par rareté : les **88 espèces** du catalogue de familiers,
dragon, Wither, Warden et creeper compris. Les œufs natifs sont réapprovisionnés
chaque seconde dans leurs emplacements vides. Prendre un œuf puis le placer
dans la case **Familier** de l'inventaire. Nom, personnalité, taille, techniques,
santé et comportement utilisent les règles actuelles ; pas de normalisation
des individus ni de nouvelle forme d'œuf.
- **À gauche : punching-ball zombie**, sans IA ni déplacement, ancré en
`(-12,81,4)`. Il reçoit les dégâts natifs avec armure naturelle, immunité
temporaire entre coups et animation de blessure. Sa vie est restaurée à
chaque tick, avant la mort si le coup est létal. Le même zombie survit.
Le tableau affiche coups acceptés, derniers PV perdus, total et attaquant.
Les valeurs sont des dégâts après réduction, pas un DPS ; un coup dépassant
la réserve de 1 024 PV ne mesure que les PV effectivement retirés.
- **À droite : bouton « 1 hostile aléatoire »**. Chaque pression ajoute un
adversaire avec IA native et cible le joueur. Deux apparitions consécutives
ne sélectionnent pas la même espèce. Il est possible de former un groupe,
jusqu'à **128 hostiles actifs**, avec refus si le point d'apparition est occupé.
La liste contient 23 types adaptés à une arène terrestre, hors boss et
créatures exigeant eau ou structures particulières.
- **Bouton de nettoyage** : retire les hostiles de l'arène, leurs descendants
(petits slimes, vex invoqués) et les projectiles locaux. Le familier et la
cible sont conservés. **Bouton de remise à zéro** : efface seulement le compteur.
Les 23 hostiles : zombie, squelette, husk, stray, bogged, parched, araignée,
araignée venimeuse, creeper, pillager, vindicator, sorcière, poisson d'argent,
endermite, slime, cube de magma, breeze, blaze, wither squelette, piglin brute,
zoglin, ravageur et evoker. Les attaques et déplacements restent natifs ;
les squelettes/pillagers ont leurs armes et les morts-vivants sont protégés
du soleil. Une couverture et des parois vitrées délimitent le terrain.
La difficulté est **Normale**, le personnage est en **Survie**, avec les blocs de la carte protégés, avec capacités
de vie, faim et inventaire complètes, accès aux techniques, épée et armure en
fer, nourriture et étiquettes. Ces facilités concernent seulement le personnage
du laboratoire. Les coups des hostiles blessent réellement le joueur et le
familier ; un K.-O. et les délais de récupération restent ceux du jeu actuel.
Inventaire conservé à la mort, apparitions naturelles désactivées, terrain
protégé des monstres, midi et ciel clair. Les ennemis de test sont exclus des
sources de maîtrise des familiers. Les affichages ont leurs libellés FR/EN.
| Action | Commande ou geste |
| --- | --- |
| Désigner une cible | H bref en visant l'ennemi |
| Ordres | H maintenu |
| Technique | G |
| Accueil et soins du joueur | `/familiers retour` |
| Se placer devant le punching-ball | `/familiers cible` |
| Ajouter un hostile | bouton ou `/familiers hostile` |
| Nettoyer | bouton ou `/familiers nettoyer` |
| Remettre le compteur à zéro | bouton ou `/familiers reset` |
`/familiars` est un alias. H/G sont les touches par défaut, configurables.
Le rappel, le changement d'individu et les délais en combat restent contrôlés
par le serveur ; attendre la fin du combat pour changer de familier.
## Lancement et reprise
```sh
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
./gradlew :sanctuary-test:exportDuoLaunch
python3 scripts/familiar_lab.py start --run review-03
```
Le nom doit être neuf. Le client de développement est explicitement **Vulkan**,
en français, avec 8 Gio de mémoire maximum et shaders Sanctuary désactivés pour
cette inspection. Le serveur écoute seulement `127.0.0.1`, sur un port libre.
Le formulaire de personnage de cette visite est rempli automatiquement avec
une identité de laboratoire. Les fichiers de lancement sont figés pour éviter
qu'un prochain build ne change une visite en cours.
```sh
python3 scripts/familiar_lab.py stop --run review-02
python3 scripts/familiar_lab.py server --run review-02
python3 scripts/familiar_lab.py client --run review-02
```
La réouverture conserve la carte et ses coffres ; les ennemis sont retirés et
la cible/le compteur sont recréés. Chaque session possède ses journaux, PID
et un manifeste. Aucun monde, JAR ou fichier de session n'est versionné.
Il n'y a pas de publication de canal ni d'installation dans Prism.
## Suite demandée, non implémentée ici
- Arrivée au stade bébé pour les espèces concernées, croissance puis évolution.
- Espèces spéciales qui arrivent directement sans stade bébé.
- Creeper qui court vers l'adversaire et déclenche une petite explosion.
Les critères de croissance, les formes, les seuils, les exceptions et les coûts
restent à décider après les essais. Le creeper conserve ses techniques actuelles.
Cette carte ne change aucun format de sauvegarde, aucune identité `sanctuary:*`
ni aucun comportement de familier du pack normal.
## Vérifications
`check build assemblePack assembleTestPack` réussit en **4 min 11 s**, avec
`-PsanctuaryFocusedTests=eggaudit -PsanctuaryAtlasOnly=true` : **15 GameTests
requis réussis**, dont les contrôles des 88 espèces. Aucun test graphique
OpenGL n'a été lancé. Journal : `build/familiar259-build-delivery.log`.
Le contrôle natif dans `checks-05` réussit : 88 œufs uniques, quatre coups dont
deux mortels (2 000 PV et `Float.MAX_VALUE`), même cible vivante, retour à son
ancrage après déplacement forcé, 60 apparitions aléatoires sans doublon
consécutif, IA et ciblage (y compris les mémoires Brain), boutons via le vrai
callback d'interaction, nettoyage qui conserve une vache témoin et la cible,
attaque réelle d'un loup attribuée au familier. Les entités invoquées sont
exclues de la maîtrise. Journal :
`build/familiars-259/checks-05/server.log`, marqueur
`FAMILIARS259_CHECKS_PASS`.
La passe élargie `eggaudit,familiar` donne **32/33 tests réussis**. Le test
historique `Familiar020GameTests.eggSizeSurvivesStorageAndChangesOfOwner`
échoue au transfert vers un autre propriétaire : le familier attendu est null.
Ce scénario et les classes de jeu du pack normal sont inchangés ; l'échec
n'est ni corrigé ni désactivé dans les sources. Journal :
`build/familiar259-build-final.log`. La première invocation avec le profil
de monde par défaut échouait avant les tests sur un cast du générateur ; le
profil AtlasOnly utilisé par les livraisons récentes permet leur exécution.
Les noms de session invalides et la préparation sur un dossier existant sont
refusés. Le script Python compile. Les deux MRpacks ont été exportés avec
packwiz et vérifiés : intégrité ZIP, version, JAR embarqués strictement
identiques aux builds, 2 520 classes du mod normal et 88 du module Test.
Toutes les classes du mod normal sont identiques à beta.258 ; seuls les
libellés de laboratoire et la version changent dans ce JAR.
Reçu et empreintes : `build/familiar259-validation.json`.
La visite **`review-02`**, fermée à la demande du créateur après les essais,
a utilisé le même code que la livraison : client
Vulkan/MoltenVK 1.4.2 sur Apple M1, 8 Gio maximum, connexion de KokaLab et
arrivée enregistrées côté serveur. Journaux dans
`build/familiars-259/review-02/`. Ce lancement ne constitue pas une inspection
visuelle exhaustive des 88 familiers, ni un essai Windows ou multijoueur.
Artefacts locaux : [pack normal](../build/Sanctuary-beta.259.mrpack) et
[pack Test](../build/Sanctuary-Test-beta.259.mrpack). Aucun tag ni canal public
n'est avancé ; aucune instance Prism ou sauvegarde personnelle n'est touchée.
Contrôle natif reproductible, dans une autre session neuve :
```sh
python3 scripts/familiar_lab.py check --run checks-06
```
Le contrôle exige le marqueur `FAMILIARS259_CHECKS_PASS` et arrête son serveur
de test après vérification des coffres, coups mortels, ancrage, boutons,
hostiles variés, nettoyage et attaque native d'un loup familier.
+107
View File
@@ -0,0 +1,107 @@
# FAM-260 — Familiers après réapparition
Retours du 12 octobre 2026 sur l’arène beta.259, branche
`codex/familiar-respawn-beta260`, Minecraft **26.3**, livraison **beta.260**.
Le créateur demande de fermer le labo et de corriger les problèmes observés.
La disposition clavier de cette livraison est remplacée par
[beta.261](familiar-alt-beta261.md) : Alt bref attaque, Alt maintenu ouvre les
ordres, G garde la technique.
## Résultat attendu et correction
Après la mort, les ordres et l’attaque doivent fonctionner dès que le joueur
réapparaît, avec son œuf conservé par keep inventory ou récupéré dans sa tombe.
Minecraft remplace l’objet `ServerPlayer`, conserve son ID d’entité et remet
son compteur de ticks à zéro. L’égalité native des entités utilise cet ID :
le cache des ordres retrouvait ainsi l’état de l’ancienne vie, dont les
compteurs anti-spam dépassaient ceux du nouveau joueur. Les demandes restaient
refusées jusqu’au rattrapage de l’ancien compteur. Une revendication d’individu
pouvait également rester attachée à l’ancienne vie si aucun tick du joueur mort
n’avait précédé la réapparition.
Le callback de réapparition annule les anciennes actions, termine l’éventuel
duel, libère la revendication et retire l’ancien état d’ordres. Le visuel du
familier est retiré puis recréé pour le nouveau joueur ; le service vérifie
également l’identité de l’objet propriétaire avant de réutiliser un visuel.
Les identifiants, composants de l’œuf, registre des individus et formats de
sauvegarde restent identiques. La vie perdue, le K.-O., le rappel, les recharges
et le verrou de combat restent conservés ; mourir ne soigne pas le familier.
**Alt gauche** déclenche l’attaque rapide, avec le comportement existant
attaque/suivi selon la cible visée. **H maintenu** conserve les ordres et
**G** conserve la technique. Un appui bref sur H ne donne plus l’attaque rapide.
L’attaque et les ordres disposent de réglages distincts ; les personnalisations
de la touche des ordres sont conservées. Les libellés et l’aide sont en FR/EN.
Dans l’inventaire, le texte « Familier — placer un œuf d’apparition » est retiré
de l’infobulle d’un œuf déjà équipé. L’emplacement vide conserve son aide.
La croissance, les évolutions et le nouveau creeper restent la suite du rework.
## Vérifications
Les quatre nouveaux scénarios serveur utilisent une mort native et les vrais
callbacks de réapparition, avec le même ID d’entité et un compteur remis à zéro :
œuf conservé après une longue vie, extraction de l’œuf dans la tombe, libération
de la revendication sans autoriser deux copies simultanées, conservation du
K.-O. et du rappel. Avant correction, trois scénarios échouaient ; après
correction les quatre réussissent, avec le contrôle de démarrage Fabric.
Journaux : `build/familiar260-reproduction.log` et
`build/familiar260-regression.log` (**5 tests requis réussis**).
Le test historique de taille/transfert beta.020 donnait au deuxième joueur une
copie de l’œuf toujours équipé par le premier. Sa préparation effectue maintenant
un transfert réel : retrait chez le premier, libération puis équipement chez le
second. Le refus des copies concurrentes est vérifié séparément par le nouveau
scénario ; aucune protection des individus n’est retirée.
`./gradlew check build assemblePack assembleTestPack
-PsanctuaryFocusedTests=eggaudit,familiar,familiar260,accessories
-PsanctuaryAtlasOnly=true` réussit en **2 min 50 s**, avec **44 GameTests requis
réussis**, dont le transfert historique corrigé. Journal :
`build/familiar260-build.log`.
Le parcours client réussit en **41 s** sur **Vulkan / MoltenVK 1.4.2, Apple M1**,
avec 8 Gio maximum. Il vérifie Alt natif, maintien H, G inchangé, Alt ignoré dans
l’inventaire, reconfiguration vers K, absence du texte redondant dans les vraies
infobulles FR/EN, puis mort native et bouton Réapparaître. Le joueur revient
avec le même ID d’entité, un nouvel objet joueur et le même individu ; les ordres
fonctionnent et le loup inflige réellement des dégâts au zombie. Journal :
`build/familiar260-client.log`, marqueur `FAMILIAR260_CLIENT_PASS`.
Reproduction du parcours, dans un nouveau monde plat graine **260** :
```sh
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryFamiliar260ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
Captures de l’inventaire FR/EN et des ordres après réapparition dans
`mods/sanctuary/build/run/clientGameTest/screenshots/000*_familiar260-*.png`.
Le contrôle des infobulles utilise leur méthode native avec le slot équipé
survolé ; les captures d’inventaire n’affichent pas cette infobulle. Cette passe
couvre macOS Vulkan avec serveur intégré ; Windows et multijoueur distant
restent à vérifier.
Les MRpacks normal et Test sont exportés et vérifiés : intégrité ZIP, version
beta.260, Minecraft 26.3, JAR embarqués identiques aux builds, classes embarquées
identiques aux sorties de compilation. Parmi les **2 520 classes** normales,
seules les quatre classes du correctif changent face à beta.259 ; les **88
classes** du module de laboratoire restent identiques. Reçu et SHA-256 :
`build/familiar260-validation.json`.
[Pack normal beta.260](../build/Sanctuary-beta.260.mrpack) ·
[Pack Test beta.260](../build/Sanctuary-Test-beta.260.mrpack).
## Séance et distribution
La visite beta.259 `review-02` est fermée, ses fichiers de développement sont
conservés. Le carnet de séance et les transcriptions sont dans
`/Users/koka/Documents/sanctuary/retours-sessions/2026-10-12_02-45_familiers-beta259/`.
Les corrections sont livrées localement ; aucune publication de canal ni
installation Prism n’est demandée. Les binaires figés de la visite beta.259
ne sont pas remplacés.
+95
View File
@@ -0,0 +1,95 @@
# APT-238 — Forgerie
Branche `codex/forgerie-beta238`, base beta.237 (`5dee981`).
## Contrat avant implémentation
Une aptitude Forgerie est ajoutée dans Arts et métiers de Progression, avec
l'icône `aptitudes_forgerie.png` fournie. Comme Enchantement et Alchimie,
elle coûte quatre niveaux, est achetée une seule fois et reste acquise
sans interrupteur. Elle débloque l'usage personnel de toutes les variantes
d'enclume et de la table de forge. Avant l'achat, les menus natifs de ces
postes sont refusés côté serveur, y compris les postes fonctionnels portés
sur la tête. Les réparations, noms, améliorations et ornements natifs restent
fonctionnels après achat. Équipements déjà obtenus et recettes conservés.
La meule, les fours et les autres postes restent sous leurs règles actuelles.
Les deux aides World Map / Mob Names restent les seuls choix désactivables.
Les interfaces ajoutées sont traduites en français et en anglais.
## Persistance et compatibilité
L'attachement booléen additif `sanctuary:smithing_learned` conserve l'achat.
Absent : false. Copie à la mort et conservation au New Game+. Aucun schéma,
aucune preuve ni aucun attachement existant n'est réécrit. Les personnages
existants avec progression doivent aussi acquérir Forgerie. Les joueurs
hors progression Sanctuary gardent l'accès vanilla. Aucun monde personnel,
aucune génération ni aucun terrain n'est modifié.
La capacité `sanctuary:smithing_v1` et l'état `sanctuary:smithing_state_v1`
transmettent l'achat sans modifier
les paquets v1 existants. Le serveur exige un client compatible et contrôle
la session, le joueur, la révision et les niveaux avant l'achat. Un retour
à une version ancienne peut perdre cet attachement inconnu à la sauvegarde ;
conserver une sauvegarde avant ce retour.
## Vérifications et livraison
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=forgerie238,aptitudes236,headblocks \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryAptitudes236ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
```
`check build assemblePack` réussi en **2 min 44 s**, **21 GameTests requis**
réussis : cinq nouveaux scénarios Forgerie, six scénarios d'aptitudes et dix
scénarios de postes portés. Contrôles purs du dépôt réussis. Le banc Atlas
évite le chemin de génération par défaut déjà documenté en beta.232.
Log `build/forgerie238-check-build-pack.log`.
- Achat refusé avec trois niveaux ou une révision périmée ; quatre niveaux
dépensés une seule fois. Autres achats et preuves inchangés. Requête de
désactivation refusée ; conservation native NBT, à la mort et au New Game+.
- Trois variantes d'enclume refusées avant achat puis ouvertes après achat.
**Réparation et renommage réels d'une épée diamant** sur chacune ; XP et
diamant consommés selon le coût natif du menu.
- Table de forge refusée avant achat puis ouverte : **épée netherite et
plastron diamant avec ornement Sentry réellement produits**, entrées
consommées une fois, coût d'XP natif conservé et accès aux visites suivantes.
- Menus construits en dehors de l'ouverture normale : validité et prise de
résultat refusées avant acquisition, sans dépense ni perte d'entrées.
Joueurs sans progression Sanctuary autorisés comme en vanilla.
- Enclumes et table de forge sur la tête : contrôle de l'achat du visiteur,
indépendamment du porteur. La fixture générale des postes natifs possède
explicitement les trois aptitudes des Arts avant de tester leurs menus.
Sources et bytecode du **Minecraft 26.3 local** vérifiés pour les méthodes
héritées de `ItemCombinerMenu`, l'override de prise d'`AnvilMenu` et les recettes
natives de forge. Aucun changement de recette ni de dépendance externe.
Parcours graphique en **34 s**, Vulkan / MoltenVK 1.4.2 sur Apple M1,
FR/EN GUI 2/3/4. La suite d'aptitudes existante contrôle désormais treize
aptitudes, dont onze achats permanents. Trois Arts achetés depuis les boutons
natifs : douze niveaux au total, Forgerie synchronisée depuis le serveur et
présentée comme acquise sans interrupteur. Deux seules aides désactivables,
XP et progression conservées lors de douze paires de changement, disposition
Pause intégrée, défilement et retour Terminé conservés. Log
`build/forgerie238-client-vulkan.log`, marqueur `FORGERIE238_PASS`.
Vingt-quatre captures dans `build/forgerie238-screenshots/` ; Arts FR GUI 3
et EN GUI 4 inspectés, texte et icône contenus dans le cadre.
Versions du mod, du pack et du manifeste alignées sur **beta.238**.
[MRpack local](../build/Sanctuary-beta.238.mrpack) vérifié : 12802487 octets,
intégrité ZIP, Minecraft 26.3 / Fabric Loader 0.19.5, un seul JAR Sanctuary
identique au build final, sans classe de test, monde ou module de laboratoire.
Les trois icônes fournies des Arts sont identiques octet pour octet aux
originaux dans le JAR. Reçu `build/forgerie238-artifact.json`.
- SHA-256 JAR : `e29ea6f7f56c57290d0307076f641f2e714ec72a80be858a5fd9c5f08e27eb8e`.
- SHA-256 MRpack : `2fbd605acf555dd8b192c0c0ad7171414a13f5820e5d440ba54debcd4cb897a7`.
Tests dans des mondes de développement neufs. Pas de validation Windows ni
multijoueur distant. Les sauvegardes et installations personnelles restent
intactes ; aucune publication ni avancée du canal packwiz.
+59
View File
@@ -0,0 +1,59 @@
# HELLO-263 — Bébés familiers dans Hello World
Correction demandée le 16 octobre 2026 sur `codex/hello-world-menu`, après
beta.262. Minecraft **26.3**, livraison locale **beta.263**.
La disposition est ensuite ajustée dans [HELLO-264](hello-layout-beta264.md) :
colonne de la largeur de la hotbar et aperçus miniatures sur un sol commun.
Le familier est présenté dans sa forme bébé native, comme le proxy de rendu
en jeu après `Mob.setBaby(true)`. Le choix concerne le modèle, les textures
bébé et l'échelle d'âge ; le cadrage tient compte de la taille réduite.
Les textures du chat et du loup sont explicitement remplacées par celles
du chaton et du louveteau. Les espèces sans forme bébé native gardent leur
apparence habituelle de familier miniature.
Le chaton reprend la variante noire par défaut du proxy natif ; le poisson
tropical reprend également son motif natif par défaut.
Le rendu reste utilisable avant la liaison des composants d'items et avant
l'arrivée d'un monde client. Aucun changement des familiers remis, des règles
serveur ou des sauvegardes. Hotbar de neuf couleurs, fond texturé, skin du
personnage et suivi de la souris conservés.
## Vérifications
Le parcours Hello World existant est étendu : après le contrôle à froid des
44 modèles/textures, l'âge, l'échelle d'âge et la texture de chaque aperçu
sont comparés à un vrai proxy natif créé dans un monde de développement
neuf, puis passé par `Mob.setBaby(true)`. Ces proxies ne sont jamais ajoutés
au monde ou simulés. Le personnage, la pagination, les saisies, l'admission,
la reconnexion et le code d'accès restent couverts par le même parcours.
```sh
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryHello262ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=progression,starter,starter052 -PsanctuaryAtlasOnly=true
```
Parcours client réussi en **1 min 26 s**, **Vulkan / MoltenVK 1.4.2 sur
Apple M1**, avec 8 Gio maximum. Journal : `build/hello263-client-vulkan.log`,
marqueurs `HELLO263_BABIES_PASS`, `HELLO262_MODELS_PASS` et
`HELLO262_CLIENT_PASS`. Les 44 âges, échelles d'âge et textures correspondent
aux proxies natifs ; la création, la reconnexion et le code d'accès passent.
24 captures conservées dans `build/hello263-screenshots/` ; galerie à froid
et poussin dans le menu inspectés.
`check build assemblePack` réussit en **3 min 4 s**, avec **13 GameTests
serveur requis réussis**. Journal : `build/hello263-check-build-pack.log`.
Le [pack local beta.263](../build/Sanctuary-beta.263.mrpack) est exporté et
vérifié : intégrité ZIP, version, Minecraft 26.3, JAR identique au build et
classes de test absentes. Face à beta.262, seule la classe de production
`HelloFamiliarPreview` change. Reçu : `build/hello263-validation.json`.
Aucune publication ou installation personnelle effectuée. Les variantes
visuelles restent celles par défaut ; Windows et serveur distant restent
hors des essais.
+74
View File
@@ -0,0 +1,74 @@
# HELLO-264 — Colonne centrale et sol commun
Demande du 16 octobre 2026 sur `codex/hello-world-menu`, après beta.263.
Minecraft **26.3**, livraison locale **beta.264**.
Le titre Hello World est retiré de l'affichage. Le menu central reprend les
**182 pixels de la hotbar native**, avec neuf couleurs par page. La section
Personnage contient le choix de couleur puis « Écris ta biographie ». Elle est
suivie du choix parmi les trois œufs et du nom du familier. Les noms sont
placés au-dessus des aperçus : pseudo coloré à gauche, nom saisi à droite.
Les flèches restent dans la largeur de la colonne ; leur infobulle indique
la page courante. Libellés français et anglais.
Les deux aperçus partagent la même hauteur de sol. Le familier garde sa forme
bébé et une taille miniature ; son échelle ne cherche plus à remplir la zone.
Le cadrage est limité à l'aperçu. Le personnage compense la rotation verticale
pour garder ses pieds au sol. Les deux modèles suivent toujours la souris.
L'espèce, la rareté et la description apparaissent uniquement au survol de
l'œuf ou de l'aperçu, et ne prennent plus de place dans le formulaire.
Retour est placé en bas du contenu, séparé des sections. « Entrer dans
Sanctuary » occupe un pied de page natif de 33 pixels, à la position du bouton
Terminé des statistiques. Les deux boutons ont la largeur de la hotbar.
Le fond légèrement texturé, les brouillons, la pagination, le verrouillage
d'identité et le parcours de code d'accès sont conservés.
Les règles serveur, les sauvegardes et les familiers remis restent inchangés.
Les variantes visuelles sont celles par défaut, comme dans beta.263.
## Vérifications
Le parcours Vulkan vérifie FR/EN en GUI 2/3/4, la largeur et le centrage des
champs, les boutons sans chevauchement et la place du pied de page natif.
Il couvre aussi les neuf couleurs par page, la dernière page incomplète,
les saisies après redimensionnement/refus serveur, l'identité verrouillée et
le code d'accès. Les aides de saisie FR/EN sont raccourcies pour tenir dans
la colonne, y compris lorsque les champs sont vides.
Les 44 modèles de départ sont rendus à froid et comparés aux vrais proxies
natifs après `Mob.setBaby(true)`. Captures spécifiques du bébé zombie,
du bébé chameau, du survol et des deux directions de souris. Admission avec
nom, sauvegarde et reconnexion dans un monde de développement neuf, graine
52 ; code d'accès avec réponse HTTP perdue, nouvel essai et admission unique
dans un autre monde neuf, graine 166.
Parcours final réussi en **1 min 4 s**, sous **Vulkan / MoltenVK 1.4.2,
Apple M1**, avec 8 Gio maximum. Les marqueurs `HELLO262_MODELS_PASS`,
`HELLO263_BABIES_PASS`, `STARTER052_CLIENT_PASS`, `ACCESS166_CLIENT_PASS`
et `HELLO262_CLIENT_PASS` figurent dans `build/hello264-client-vulkan.log`.
**43 captures natives** conservées dans `build/hello264-screenshots/` ;
menus FR/EN, zombie, chameau, suivi de souris, cadrage et infobulle inspectés.
`check build assemblePack` réussit en **3 min 9 s**, avec les **13 GameTests
serveur requis réussis**. Journal : `build/hello264-check-build-pack.log`.
Le [pack local beta.264](../build/Sanctuary-beta.264.mrpack) est exporté et
vérifié : intégrité ZIP, version, Minecraft 26.3, JAR identique au build,
classes de production identiques aux sorties compilées, traductions FR/EN
identiques aux sources et classes de test absentes. Par rapport à beta.263,
les seules classes de production modifiées sont `HelloWorldScreen` et ses
classes internes, ainsi que `HelloFamiliarPreview`.
Reçu : `build/hello264-validation.json`.
Aucune publication ou installation personnelle effectuée. Essais graphiques
sur Apple M1 uniquement ; Windows et serveur distant hors des essais.
```sh
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryHello262ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=progression,starter,starter052 -PsanctuaryAtlasOnly=true
```
+96
View File
@@ -0,0 +1,96 @@
# HELLO-262 — Menu de création du personnage et du familier
Demande du 16 octobre 2026, branche `codex/hello-world-menu`, socle beta.261,
Minecraft **26.3**, livraison locale **beta.262**.
Les aperçus adultes de cette première version sont remplacés par les
[formes bébé en beta.263](hello-baby-familiars-beta263.md).
## Menu
Trois colonnes centrées : personnage à gauche, formulaire au centre, familier
à droite. Les colonnes latérales ont la même largeur ; le centre est partagé
en deux blocs de même hauteur, personnage en haut et familier en bas.
Aucun aplat de panneau : fond gris sombre légèrement texturé sur toute la page,
avec la texture native de pierre teintée, proche du précédent `#171717`.
Le pseudo affiche la couleur choisie. Les flèches autour de lui parcourent
**neuf couleurs serveur par page**. La barre reprend directement les sprites
`minecraft:hud/hotbar` et `minecraft:hud/hotbar_selection`, sans texture dérivée.
Aux petites tailles, elle se réduit proportionnellement tout en conservant ses
neuf cases. La dernière page garde les emplacements vides inactifs. Noms des
couleurs au survol et dans la narration ; libellés FR/EN.
Le personnage et le familier ont des **aperçus 3D qui suivent la souris**,
sans devoir cliquer ou glisser. Le skin utilise le widget natif ; le familier
utilise ses modèles et textures natifs. Les trois œufs restent les boutons de
choix à droite. Le survol change l'aperçu ; sinon, le familier choisi est
montré, ou le premier avant la sélection explicite.
Ces aperçus ne créent aucun `ItemStack`, entité ou monde de substitution.
Les états graphiques sont construits directement avant l'admission. Les vaches,
poules et cochons utilisent explicitement leurs variantes tempérées 26.3,
sans lier ou remplacer les registres dynamiques du serveur.
La biographie et le nom du familier restent au centre. Le code d'accès apparaît
lorsque le serveur l'exige. Pagination, redimensionnement et refus serveur
conservent les saisies et le familier proposé ; une couleur devenue indisponible
est retirée de la sélection. Une identité enregistrée reste verrouillée.
Règles serveur, tirage, protocole et formats de sauvegarde inchangés.
## Vérifications
Parcours client **Vulkan / MoltenVK 1.4.2, Apple M1**, 8 Gio maximum : FR/EN,
fenêtre 1280 × 960, GUI 2/3/4, soit 640 × 480, 427 × 320 et 320 × 240.
Toutes les couleurs sont accessibles sans doublon, dernière page comprise.
Contrôles sans chevauchement, boutons Annuler/Entrer lisibles sans défilement,
focus des flèches conservé, brouillons et sélection conservés au redimensionnement.
Offres sans familier, palette vide et identité verrouillée couvertes.
Les **44 modèles de départ** communs/peu communs sont soumis au rendu natif
et leurs textures vérifiées sur un client à froid, **avant la liaison des
composants d'items**. La galerie est rendue en Vulkan. Les déplacements du
curseur vérifient les deux axes du familier et du personnage ; captures
comparées pour contrôler le sens de rotation.
Admission avec nom et reconnexion dans un monde plat neuf, graine **52**.
Admission par code dans un autre monde plat neuf, graine **166**, avec une API
HTTP locale contrôlée : réponse perdue, nouvel essai et admission unique.
Configuration temporaire restaurée. Les parcours Starter052 et Access166 sont
réutilisés ; les assertions historiques de disposition de Starter052 et
Progression003 sont adaptées.
```sh
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryHello262ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=progression,starter,starter052 -PsanctuaryAtlasOnly=true
```
Parcours client final réussi en **1 min 35 s**. Journal :
`build/hello262-3d-client-vulkan-final.log`, marqueurs `HELLO262_MODELS_PASS`
et `HELLO262_CLIENT_PASS`. **24 captures finales** conservées dans
`build/hello262-final-screenshots/` ; vues FR/2, FR/4 avec accès, EN/4,
galerie des 44 modèles et deux positions du curseur inspectées.
`check build assemblePack` réussit en **3 min 32 s**, avec **13 GameTests
serveur requis réussis** et les contrôles purs du dépôt. Journal :
`build/hello262-check-build-pack-final.log`. Mod et pack alignés sur beta.262.
[Pack local beta.262](../build/Sanctuary-beta.262.mrpack).
Reçu et empreintes : `build/hello262-validation.json`.
Le MRpack est vérifié après export : intégrité ZIP, Minecraft 26.3, version
beta.262 et JAR Sanctuary identique au build ; classes de test absentes du mod.
## Limites
Aperçus avec les variantes visuelles par défaut, sans choix de variante.
Aux petites tailles, la description latérale est abrégée par la place disponible ;
l'infobulle garde le texte complet. La biographie conserve sa limite de
256 caractères et son champ natif à défilement horizontal.
Essais sur serveurs intégrés de développement ; Windows et serveur distant
non testés. Aucune publication, modification du canal packwiz ou synchronisation
d'une installation personnelle effectuée.
+278
View File
@@ -0,0 +1,278 @@
{
"version": "beta.248",
"retrieved": "2026-10-08",
"encoding": "OGG Vorbis, mono, 32000 Hz, q4",
"changes": "Trim, frequency filtering, mild noise reduction, loudness adjustment, 60 ms fades, mono re-encoding",
"files": [
{
"species": "brown_cicada",
"file": "cicada_brown.ogg",
"title": "Aburazemi 07z7315.ogg",
"author": "ISAKA Yoji (Cory)",
"source": "https://commons.wikimedia.org/wiki/File:Aburazemi_07z7315.ogg",
"download": "https://upload.wikimedia.org/wikipedia/commons/d/d2/Aburazemi_07z7315.ogg",
"license": "CC BY 2.1 Japan",
"license_url": "https://creativecommons.org/licenses/by/2.1/jp/",
"input_sha256": "621f514c83e8096e1ee8f6c40e6a6cdeb4ea8227584e74f34c86724e18596471",
"sha256": "083751d055c07086457fe8c0198be90429a0b487b6a0c458f2362a8e5c793e39",
"start_seconds": 25,
"duration_seconds": 4,
"filters": "highpass=f=800,lowpass=f=8500,afftdn=nr=8:nf=-35,loudnorm=I=-24:TP=-9:LRA=5,afade=t=in:st=0:d=0.06,afade=t=out:st=3.94:d=0.06",
"format": {
"programs": [],
"stream_groups": [],
"streams": [
{
"codec_name": "vorbis",
"sample_rate": "32000",
"channels": 1
}
],
"format": {
"duration": "4.000000"
}
},
"notes": "Graptopsaltria nigrofuscata, field recording"
},
{
"species": "robust_cicada",
"file": "cicada_robust.ogg",
"title": "Hyalessa maculaticollis v01.ogg",
"author": "Σ64",
"source": "https://commons.wikimedia.org/wiki/File:Hyalessa_maculaticollis_v01.ogg",
"download": "https://upload.wikimedia.org/wikipedia/commons/9/9b/Hyalessa_maculaticollis_v01.ogg",
"license": "CC BY 3.0",
"license_url": "https://creativecommons.org/licenses/by/3.0/",
"input_sha256": "a43db3363bf829af3db9d4fbd576a778e20f47b1a2caf8cff8c7963ee5ea2547",
"sha256": "c6117adf6dc03d83e12e66acf20221bead2fda1016cffe49ca78e1d81980cb30",
"start_seconds": 12,
"duration_seconds": 5.5,
"filters": "highpass=f=800,lowpass=f=8500,afftdn=nr=8:nf=-35,loudnorm=I=-24:TP=-9:LRA=5,afade=t=in:st=0:d=0.06,afade=t=out:st=5.44:d=0.06",
"format": {
"programs": [],
"stream_groups": [],
"streams": [
{
"codec_name": "vorbis",
"sample_rate": "32000",
"channels": 1
}
],
"format": {
"duration": "5.482219"
}
},
"notes": "Hyalessa maculaticollis, field recording"
},
{
"species": "walker_cicada",
"file": "cicada_walker.ogg",
"title": "Meimuna opalifera v02.ogg",
"author": "Σ64",
"source": "https://commons.wikimedia.org/wiki/File:Meimuna_opalifera_v02.ogg",
"download": "https://upload.wikimedia.org/wikipedia/commons/e/e5/Meimuna_opalifera_v02.ogg",
"license": "CC BY 3.0",
"license_url": "https://creativecommons.org/licenses/by/3.0/",
"input_sha256": "eb7b2383e79da540ad1f0b23d1c707b99adb8bfec258ff9b9d105c084dda3c61",
"sha256": "d39420ab4b4ba5ed096cc78947e83b16e9d1897f6900429367f3b58a97826212",
"start_seconds": 9,
"duration_seconds": 6,
"filters": "highpass=f=1200,lowpass=f=8500,afftdn=nr=8:nf=-35,loudnorm=I=-24:TP=-9:LRA=5,afade=t=in:st=0:d=0.06,afade=t=out:st=5.94:d=0.06",
"format": {
"programs": [],
"stream_groups": [],
"streams": [
{
"codec_name": "vorbis",
"sample_rate": "32000",
"channels": 1
}
],
"format": {
"duration": "6.000000"
}
},
"notes": "Meimuna opalifera, Kawasaki 2011; new recording selected after mechanical noise was reported in the previous source; original 44.1 kHz"
},
{
"species": "evening_cicada",
"file": "cicada_evening.ogg",
"title": "Higurashi 20180728 1902.ogg",
"author": "あおもりくま (aomorikuma)",
"source": "https://commons.wikimedia.org/wiki/File:Higurashi_20180728_1902.ogg",
"download": "https://upload.wikimedia.org/wikipedia/commons/transcoded/b/bb/Higurashi_20180728_1902.ogg/Higurashi_20180728_1902.ogg.mp3",
"license": "CC BY-SA 3.0",
"license_url": "https://creativecommons.org/licenses/by-sa/3.0/",
"input_sha256": "bc1b0270510393b3ee8831254847279f85e0e2382b4d09e12266b3d216587dc7",
"sha256": "3f8f82412c9f6f22f5bce07464be7a4e0c6843d914c4cfa694dfe45fca5b6cdb",
"start_seconds": 3.2,
"duration_seconds": 6,
"filters": "highpass=f=800,lowpass=f=8500,afftdn=nr=8:nf=-35,loudnorm=I=-24:TP=-9:LRA=5,afade=t=in:st=0:d=0.06,afade=t=out:st=5.94:d=0.06",
"format": {
"programs": [],
"stream_groups": [],
"streams": [
{
"codec_name": "vorbis",
"sample_rate": "32000",
"channels": 1
}
],
"format": {
"duration": "6.000000"
}
},
"notes": "Tanna japonensis, public Commons MP3 transcode"
},
{
"species": "cricket",
"file": "cricket.ogg",
"title": "Single cricket up close",
"author": "ClaudeDebussy",
"source": "https://freesound.org/people/ClaudeDebussy/sounds/428679/",
"download": "https://cdn.freesound.org/previews/428/428679_5736710-hq.mp3",
"license": "CC0 1.0",
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
"input_sha256": "43e0b17687da18dfe99e7e3940bf89f1ffff8b9263a5b9fef3c2f8ff6eaa6618",
"sha256": "99c79279fffba00c52918a62468c16a2049d142c8164773a322ebc48ecc22e0c",
"start_seconds": 1.4,
"duration_seconds": 3,
"filters": "highpass=f=1800,lowpass=f=8500,afftdn=nr=8:nf=-35,loudnorm=I=-24:TP=-9:LRA=5,afade=t=in:st=0:d=0.06,afade=t=out:st=2.94:d=0.06",
"format": {
"programs": [],
"stream_groups": [],
"streams": [
{
"codec_name": "vorbis",
"sample_rate": "32000",
"channels": 1
}
],
"format": {
"duration": "3.000000"
}
},
"notes": "Single cricket up close; biological species unspecified; public HQ preview"
},
{
"species": "bell_cricket",
"file": "bell_cricket.ogg",
"title": "Suzumushi 06z3310.ogg",
"author": "ISAKA Yoji (Cory)",
"source": "https://commons.wikimedia.org/wiki/File:Suzumushi_06z3310.ogg",
"download": "https://upload.wikimedia.org/wikipedia/commons/b/b0/Suzumushi_06z3310.ogg",
"license": "CC BY 2.1 Japan",
"license_url": "https://creativecommons.org/licenses/by/2.1/jp/",
"input_sha256": "6a3305fef82fc101a21dab2917899fcaa9bfe0bbf03deb826fefa500fc97476a",
"sha256": "acb8481493ca69229e15e9332a7b2171c8a35c07d19c11aecc55b85427749b81",
"start_seconds": 2,
"duration_seconds": 4,
"filters": "highpass=f=4300,lowpass=f=6000,afftdn=nr=8:nf=-35,loudnorm=I=-24:TP=-9:LRA=5,afade=t=in:st=0:d=0.06,afade=t=out:st=3.94:d=0.06",
"format": {
"programs": [],
"stream_groups": [],
"streams": [
{
"codec_name": "vorbis",
"sample_rate": "32000",
"channels": 1
}
],
"format": {
"duration": "4.000000"
}
},
"notes": "Field recording with several insects; band limited bell cricket call, some insect background remains"
},
{
"species": "mole_cricket",
"file": "mole_cricket.ogg",
"title": "Gryllotalpidae 06-15.ogg",
"author": "YaCoUYaCoU",
"source": "https://commons.wikimedia.org/wiki/File:Gryllotalpidae_06-15.ogg",
"download": "https://upload.wikimedia.org/wikipedia/commons/8/84/Gryllotalpidae_06-15.ogg",
"license": "CC BY 4.0",
"license_url": "https://creativecommons.org/licenses/by/4.0/",
"input_sha256": "2cd180f1e048819211607551ebe48215ff36957dd5d06ece9fb5db699fb65b69",
"sha256": "54e5cd5de697f1987d51a51c6985822b6c53be9b31962672898a165b151f190e",
"start_seconds": 2,
"duration_seconds": 4,
"filters": "highpass=f=600,lowpass=f=3500,afftdn=nr=8:nf=-35,loudnorm=I=-24:TP=-9:LRA=5,afade=t=in:st=0:d=0.06,afade=t=out:st=3.94:d=0.06",
"format": {
"programs": [],
"stream_groups": [],
"streams": [
{
"codec_name": "vorbis",
"sample_rate": "32000",
"channels": 1
}
],
"format": {
"duration": "4.000000"
}
},
"notes": "Gryllotalpa gryllotalpa; family analogue, not an assertion of the Wild World biological species"
},
{
"species": "fly",
"file": "fly_flight.ogg",
"title": "Fly_Buzzing_Edited.wav",
"author": "AmberdeMeillon",
"source": "https://freesound.org/people/AmberdeMeillon/sounds/443059/",
"download": "https://cdn.freesound.org/previews/443/443059_8282799-hq.mp3",
"license": "CC0 1.0",
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
"input_sha256": "3246954a2e9131d8b471af4a383603f2aba81e28d3288a06203bdac97a040da7",
"sha256": "ab5cb6240b7b3a369700db052c6c7c0b10df8bbedd8435328fda374f2e6f130e",
"start_seconds": 0.25,
"duration_seconds": 3.2,
"filters": "highpass=f=100,lowpass=f=3000,afftdn=nr=8:nf=-35,loudnorm=I=-24:TP=-9:LRA=5,afade=t=in:st=0:d=0.06,afade=t=out:st=3.14:d=0.06",
"format": {
"programs": [],
"stream_groups": [],
"streams": [
{
"codec_name": "vorbis",
"sample_rate": "32000",
"channels": 1
}
],
"format": {
"duration": "3.200000"
}
},
"notes": "House fly buzzing against a window; public HQ preview"
},
{
"species": "mosquito",
"file": "mosquito_flight.ogg",
"title": "Mosquito 1 Edit.wav",
"author": "IanFSA",
"source": "https://freesound.org/people/IanFSA/sounds/662970/",
"download": "https://cdn.freesound.org/previews/662/662970_4287394-hq.mp3",
"license": "CC0 1.0",
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
"input_sha256": "b664a9abef3ee7192ba3c0342ebc42ef3fb0fd133a5054a2b689c590fd54d571",
"sha256": "090cfa519491b3fcc00f6aafda1af5bc088103d89a47c672150e8b7d5f6f5f87",
"start_seconds": 3,
"duration_seconds": 4,
"filters": "highpass=f=200,lowpass=f=5000,afftdn=nr=8:nf=-35,loudnorm=I=-24:TP=-9:LRA=5,afade=t=in:st=0:d=0.06,afade=t=out:st=3.94:d=0.06",
"format": {
"programs": [],
"stream_groups": [],
"streams": [
{
"codec_name": "vorbis",
"sample_rate": "32000",
"channels": 1
}
],
"format": {
"duration": "4.000000"
}
},
"notes": "Mosquitos in a bathroom; public HQ preview"
}
]
}
+23
View File
@@ -0,0 +1,23 @@
# Insect audio — Sanctuary beta.248
These nine audio assets retain their individual licenses, separate from the GPL code.
Adaptation by Sanctuary: excerpt, filtering, noise reduction, gain, mono OGG re-encoding and fades.
No endorsement by the recording authors is implied.
The adapted evening cicada recording is distributed under CC BY-SA 3.0.
| Asset | Recording / source | Author | License | Excerpt (seconds) |
| --- | --- | --- | --- | --- |
| `cicada_brown.ogg` | [Aburazemi 07z7315.ogg](https://commons.wikimedia.org/wiki/File:Aburazemi_07z7315.ogg) | ISAKA Yoji (Cory) | [CC BY 2.1 Japan](https://creativecommons.org/licenses/by/2.1/jp/) | 25–29 |
| `cicada_robust.ogg` | [Hyalessa maculaticollis v01.ogg](https://commons.wikimedia.org/wiki/File:Hyalessa_maculaticollis_v01.ogg) | Σ64 | [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/) | 12–17.5 |
| `cicada_walker.ogg` | [Meimuna opalifera v02.ogg](https://commons.wikimedia.org/wiki/File:Meimuna_opalifera_v02.ogg) | Σ64 | [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/) | 9–15 |
| `cicada_evening.ogg` | [Higurashi 20180728 1902.ogg](https://commons.wikimedia.org/wiki/File:Higurashi_20180728_1902.ogg) | あおもりくま (aomorikuma) | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) | 3.2–9.2 |
| `cricket.ogg` | [Single cricket up close](https://freesound.org/people/ClaudeDebussy/sounds/428679/) | ClaudeDebussy | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) | 1.4–4.4 |
| `bell_cricket.ogg` | [Suzumushi 06z3310.ogg](https://commons.wikimedia.org/wiki/File:Suzumushi_06z3310.ogg) | ISAKA Yoji (Cory) | [CC BY 2.1 Japan](https://creativecommons.org/licenses/by/2.1/jp/) | 2–6 |
| `mole_cricket.ogg` | [Gryllotalpidae 06-15.ogg](https://commons.wikimedia.org/wiki/File:Gryllotalpidae_06-15.ogg) | YaCoUYaCoU | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | 2–6 |
| `fly_flight.ogg` | [Fly_Buzzing_Edited.wav](https://freesound.org/people/AmberdeMeillon/sounds/443059/) | AmberdeMeillon | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) | 0.25–3.45 |
| `mosquito_flight.ogg` | [Mosquito 1 Edit.wav](https://freesound.org/people/IanFSA/sounds/662970/) | IanFSA | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) | 3–7 |
Public HQ previews were used for the three Freesound recordings. The evening cicada uses the Commons public MP3 transcode.
Original quality varies: the mole cricket source is 8 kHz; bell cricket retains some field insect background. The walker cicada source is 44.1 kHz.
The cricket and European mole cricket are sound design analogues; no precise Wild World biological identification is claimed.
Exact source URLs, input/output SHA-256 and transformations: `Insect-Audio-ASSETS.json`.
+112
View File
@@ -0,0 +1,112 @@
# INSECT-03 — Retours de la visite, beta.247
Demande du créateur le 8 octobre 2026, après inspection de beta.246.
Branche `codex/insect-behaviour-beta247`.
## Résultat à vérifier
- Escargot posé sur sa fleur, sans espace visible sous ses pieds.
- Clic droit au filet : espèce visible la plus proche à trois blocs, sans
accroupissement obligatoire ni visée au pixel. Les obstacles et la fuite
empêchent toujours la capture ; récompense unique côté serveur.
- Observation immobile sans fuite. Marche vers l’insecte : alerte à deux
blocs ; sprint : cinq blocs ; approche accroupie : 0,65 bloc.
- Criquets long et migrateur et sauterelle : sauts plus hauts.
- Grillon, grillon des prés, courtilière et cloporte : sprites au sol,
sans sautillement. Fourmi conservée à plat.
- Libellules : deux ailes synchronisées, rapides, corps central de deux
colonnes visible de côté, orientation selon le vol et trajets progressifs
entre points. PNG d’origine conservés.
- Puce liée au villageois, sans fuite même si le joueur s’approche.
- Bousier accompagné d’une boule de neige qui roule devant lui.
- Papillons, mouche, moustique et espèces sur troncs conservés.
Sons reportés : préparer uniquement les fichiers à fournir et leurs usages.
Conserver le filet 16 × 16, les saisons, habitats et captures silencieuses.
Créer une nouvelle visite de développement ; aucun monde personnel modifié.
## Carte et détails
Nouvelle carte plate de développement `review-04`, graine **246**, disposition
**v2** : le bousier dispose d’un carré de neige de 3 × 3 blocs, et le support
de l’escargot utilise la hauteur native de la fleur. Le marqueur `ready-v2`
refuse les visites v1 ; choisir une session neuve conserve les anciennes
cartes. La génération Sanctuary et les sauvegardes personnelles ne changent pas.
Le filet conserve une portée de trois blocs depuis le joueur, choisit le plus
proche dans la moitié avant et vérifie séparément la visibilité de chaque
candidat. Les joueurs spectateurs, les espèces déjà enfuies et les cibles
cachées par des blocs restent exclus. La détection de peur mesure les positions
serveur successives : aucun mouvement horizontal vers l’insecte, aucune alerte.
Les papillons, la mouche et le moustique gardent leurs modèles et déplacements.
Les trois libellules gardent les colonnes UV 7 et 8 pour le corps, dont deux
faces verticales de 2 pixels de haut sur 16 de long et une face supérieure.
Les deux quads d’ailes utilisent les colonnes 0–6 et 9–15, battent ensemble
et suivent la direction du vol. Le serveur choisit des points dans un rayon
horizontal de 1,8 bloc autour du support, avec ±0,3 bloc de hauteur, puis
accélère et ralentit pendant chaque trajet. La géante est la plus rapide.
Les chemins refusent les obstacles et les chunks non chargés.
La boule du bousier est une géométrie arrondie texturée avec la neige native,
sans nouveau bloc ni projectile ; sa rotation suit le déplacement serveur.
La [liste des sons](insect-sounds-plan.md) couvre les 47 parcelles. Neuf sons
prioritaires, deux facultatifs ; aucune lecture audio ajoutée à cette livraison.
## Vérifications
Les **13 GameTests requis passent** : les huit scénarios précédents, quatre
scénarios de retours et l’environnement requis. Capture debout et hors axe,
plus proche au sol, obstacles et fuite, observation immobile, approche accroupie,
puce attachée, cinq espèces à plat, contact de l’escargot, saut plus haut,
accélération de la libellule et rotation de neige synchronisée.
Les étapes manuelles des tests avancent le compteur comme `ServerLevel` avant
`Entity.tick` ; les premiers essais sans cette avance sont conservés dans
le journal initial, mais ne représentent pas le déroulement normal du jeu.
```sh
./gradlew check build assemblePack assembleTestPack \
-PsanctuaryFocusedTests=insects245 -PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryInsects245ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
```
Contrôle **Vulkan** réussi : cinq chemins de rendu, 47 sprites d’inventaire
FR/EN, clic droit natif **debout**, une capture et une usure, découvertes
silencieuses. La scène de contrôle maintient seulement ses spécimens
transitoires en inspection, car elle présente été et hiver ensemble.
La présence du bousier et sa rotation synchronisée sont contrôlées au moment
de l’image ; vision nocturne utilisée pour inspecter les formes.
Aucune texture d’insecte manquante signalée.
Images : [libellule](../build/insects247/screenshots/0001_insects247-dragonfly.png),
[bousier et boule](../build/insects247/screenshots/0002_insects247-snowball.png),
[surfaces et escargot](../build/insects247/screenshots/0000_insects247-day-surfaces.png).
Les 47 PNG d’origine et le filet 16 × 16 sont inchangés.
La visite **`review-04` est ouverte** en beta.247, Vulkan : 47 espèces
et supports validés côté serveur ; client connecté, formulaire fermé,
joueur à la première parcelle (7, 81, 2), 47 sprites et filet 16 × 16 vérifiés.
Le serveur de la visite précédente a été arrêté après fermeture de son client.
```sh
./gradlew :sanctuary-test:exportDuoLaunch
python3 scripts/insect_lab.py start --run review-04
```
Pour une autre visite, choisir un nom neuf. Les commandes `/insectes 1` à
`47`, `suivant`, `precedent`, `observer`, `jouer` et `reset` sont conservées.
**Clic droit debout** à portée suffit ; s’accroupir permet d’approcher davantage.
Les modèles restent soumis au retour visuel du créateur.
`check build assemblePack assembleTestPack` réussit sur les sources finales,
avec **13 GameTests requis** ; le contrôle Vulkan final réussit aussi.
Intégrité ZIP, versions et JAR embarqués des deux MRpack vérifiés.
219 fichiers déjà modifiés ou ajoutés avant ce ticket restent identiques ;
15 autres sont prolongés pour ces corrections. Les calendriers, horaires,
pics, habitats, tailles et identifiants sont comparés à beta.246 et inchangés.
Livraisons locales : [pack normal](../build/Sanctuary-beta.247.mrpack),
[pack Test](../build/Sanctuary-Test-beta.247.mrpack),
[reçu et empreintes](../build/insects247/validation.json).
Aucune publication ni mise à jour Prism.
+115
View File
@@ -0,0 +1,115 @@
# INSECT-04 — Libellules en croix, sons et galerie au soleil, beta.248
Demande du créateur le 8 octobre 2026, après validation visuelle de beta.247.
Branche `codex/dragonfly-cross-beta248`. Recherche d’enregistrements libres
explicitement autorisée ; conditions de test demandées : jour et ciel dégagé.
## Résultat
Les deux plans verticaux du corps des libellules deviennent un seul plan
centré en X=0. Il croise le plan supérieur et conserve les colonnes 7 et 8
du sprite. Le matériau natif `entityCutout`, sans suppression des faces
arrière, montre ce plan des deux côtés. Les deux ailes et le vol progressif
entre points restent identiques.
La galerie conserve midi, horloge native 6000 suspendue, pluie et orage à zéro.
Le module Test désactive l’horloge réelle et la météo dans ce seul serveur
de développement, puis réapplique ces conditions chaque seconde pour couvrir
l’ordre des callbacks de démarrage. La vision nocturne artificielle est retirée.
Seuls les spécimens explicitement marqués pour inspection dans un monde plat
de développement peuvent ignorer la pluie ; fleur/feuilles, espace libre et
chunks déjà chargés restent obligatoires. L’escargot naturel exige toujours
la pluie. Calendrier, habitats ordinaires, monde Sanctuary et sauvegardes
personnelles ne sont pas modifiés.
## Audio
Neuf sons positionnels mono OGG Vorbis à 32 kHz : quatre cigales, grillon,
grillon des prés, courtilière, mouche et moustique. Les autres espèces restent
silencieuses selon le [plan audio](insect-sounds-plan.md). Les chants ont des
pauses de quelques secondes entre les extraits ; les insectes volants utilisent
un bourdonnement bouclé avec des fondus courts. Le son suit l’entité visible,
s’arrête dès sa fuite/capture/disparition, et respecte le réglage Minecraft
« créatures amicales ». Portée : 12 blocs pour les cigales, 10 pour les
grillons/courtilière, 6 pour la mouche, 4 pour le moustique. Six voix proches
au maximum par client ; phases décalées et volume discret. Pas de notification
ni de son de découverte. Les sous-titres natifs facultatifs sont traduits FR/EN.
[Sources, auteurs, licences et découpes](insect-audio-credits.md) et
[empreintes des originaux/extraits](insect-audio-assets.json) sont inclus
également dans le JAR. Les ressources audio gardent leurs licences distinctes
du code. L’extrait de cigale du soir reste distribué sous CC BY-SA 3.0.
Les originaux et aperçus téléchargés sont conservés dans le dossier ignoré
`build/insects248/audio-source`, aucun binaire de dépendance ajouté au dépôt.
Les prises sont naturelles : la courtilière provient
d’une source 8 kHz ; l’extrait de grillon des prés contient encore un peu de fond
d’autres insectes malgré le filtrage. Le grillon n’est pas identifié à une
espèce biologique précise ; la courtilière européenne est un analogue sonore.
Après le retour d’écoute signalant un bruit de caméra sur la Walker cicada,
la prise de Koba-chan est remplacée par `Meimuna opalifera v02.ogg` de Σ64,
enregistrée à Kawasaki en 2011 sous CC BY 3.0. L’original est à 44,1 kHz ;
la découpe 9–15 s évite le début de la prise et filtre les basses sous 1,2 kHz.
Le réglage final du timbre et du volume attend le retour d’écoute du créateur.
Les sons du filet et de capture restent à faire si demandés.
## Vérification
`./gradlew check build assemblePack assembleTestPack` a réussi avec
`-PsanctuaryFocusedTests=insects245 -PsanctuaryAtlasOnly=true` : les treize
GameTests requis passent. Le contrôle client `runClientGameTest`, backend
Vulkan exclusivement, réussit : 47 textures et traductions FR/EN, cinq modèles,
corps de libellule en croix rendu, capture native debout, usure unique du filet,
absence de notifications d’insectes, midi et ciel clair sans vision nocturne,
escargot d’inspection au sec, neuf fichiers décodés par le lecteur OGG natif,
bourdonnement positionnel du moustique et arrêt après fuite.
Le premier essai a révélé que `SavedData.setDirty()` ne synchronise pas à lui
seul un changement d’horloge avec un client déjà connecté. Le réglage utilise
maintenant le setter natif `ServerClockManager.setTotalTicks`, qui diffuse le
paquet d’horloge. Un second démarrage de la suite s’est bloqué dans les barrières
du framework Fabric avant les tests ; seul ce client de test a été arrêté.
L’essai final réussit après correction, sans contourner l’assertion de lumière.
Le contrôle natif des neuf sons précède le changement de prise de la Walker.
Après remplacement, les neuf OGG passent de nouveau le décodage FFmpeg ;
le nouvel extrait garde le format mono Vorbis 32 kHz utilisé par le lecteur
natif. L’écoute du nouveau timbre se fait dans la visite rouverte.
Logs : `build/insects248/check-build-pack-audio-revision.log` et
`build/insects248/client-vulkan-final.log`. Les cinq captures sont copiées dans
`build/insects248/screenshots/`. La reconstruction finale intègre le remplacement de la Walker cicada.
La visite `review-05` a été ouverte en beta.248 : serveur
`127.0.0.1:51651`, seed 246, disposition v2, carte plate dédiée conservée dans
`build/insects-246/review-05`. Le journal serveur atteste les 47 espèces avec
`time=6000 rain=0.0 daylight=true` ; le journal client atteste
`time=6000 weather=clear daylight=pass`, les 47 sprites et le filet 16 × 16.
Les anciens dossiers de visite sont préservés ; Prism et les mondes personnels
ne sont pas utilisés. Navigation : `/insectes 15` à `18` pour les cigales,
`26`, `27`, `29` pour les grillons/courtilière, `45` et `46` pour moustique/mouche.
Le client `review-05` a planté pendant un build car son classpath lisait le
JAR de sortie Gradle en cours de remplacement (`invalid LOC header`).
Le lanceur copie désormais les JAR, classes, ressources et configuration
de lancement du dépôt dans `runtime/<client|server>` propre à la visite.
Les dépendances Gradle déjà stockées sous leur hash restent partagées.
Une session réutilise sa copie de lancement ; les builds suivants ne
remplacent plus les fichiers lus par cette session. Le contrôle de régression
remplace le fichier source puis vérifie que JAR/configuration copiés et
arguments de relance gardent leur contenu initial.
La visite `review-06` est rouverte en Vulkan avec la nouvelle prise :
serveur `127.0.0.1:51826`, client PID 29306, serveur PID 29266. Les deux
classpath du dépôt sont figés dans son dossier `runtime` ; leurs JAR
correspondent aux empreintes finales. Les 47 espèces, midi, météo claire
et sprites 16 × 16 passent dans les journaux de cette nouvelle visite.
Packs locaux : [Sanctuary beta.248](../build/Sanctuary-beta.248.mrpack) et
[Sanctuary Test beta.248](../build/Sanctuary-Test-beta.248.mrpack).
Archives ZIP, versions, JAR embarqués, neuf fichiers audio et crédits
vérifiés ; les 47 PNG originaux et le filet 16 × 16 sont préservés.
[Reçu final et empreintes](../build/insects248/validation.json).
218 des 236 fichiers préexistants sont identiques ; les 18 autres sont
étendus dans le périmètre de ce ticket. Aucun fichier préexistant retiré.
Aucune publication ni mise à jour du canal packwiz stable.
+77
View File
@@ -0,0 +1,77 @@
# INSECT-07 — Habitats naturels et protection des maisons, beta.251
Retour du créateur le 8 octobre 2026 : trop de sauterelles/criquets et
de libellules apparaissent sur les gisements de pierre de Sanctuary Island.
Préserver leur présence dans les prairies, sans envahir les maisons.
Branche `codex/insect-habitats-beta251`, base beta.250.
Contrat de cette passe :
- Les trois espèces sauteuses apparaissent sur une ouverture herbeuse,
sans canopée dense : prairies ou clairières, indépendamment du nom du biome.
Ni pierre, ni sable, ni sol construit. Mois, horaires et pics conservés.
- Maximum deux insectes sauteurs, espèces confondues, à 32 blocs du nouveau
point ; même plafond pour les libellules. Les poids des trois sauteurs sont
divisés par deux sans supprimer une saison ni un pic.
- Les libellules apparaissent sur l’herbe ouverte ou sur une berge de terre/
sable près d’une eau douce ; aucune apparition sur la roche nue.
- Le cloporte peut apparaître naturellement sur ou près des pierres, avec
un plafond de deux à proximité ; frapper un rocher peut toujours le révéler.
- Toute apparition naturelle est refusée sous un toit solide, même transparent.
Les feuilles sont autorisées pour les insectes forestiers ; les trajectoires
libres s’arrêtent à la limite d’un toit. Les spécimens du laboratoire
gardent leur mode d’inspection explicite.
L’ouverture herbeuse demande un support d’herbe ou de mousse à ciel ouvert,
avec au moins cinq colonnes herbeuses parmi les neuf échantillonnées dans
un carré de 5 × 5 blocs, à deux blocs de différence de hauteur au maximum.
La hauteur du terrain est lue directement : les feuilles comptent comme une
canopée pour les sauteurs et les libellules, indépendamment du délai de mise
à jour de l’éclairage. Les berges de libellules cherchent une source d’eau
douce dans un rayon de six blocs et à trois blocs de hauteur au maximum.
Le contrôle des toits examine les collisions et les fluides dans la colonne
chargée au-dessus du point réel de l’insecte, y compris pour une puce sur son
villageois. Il bloque notamment bois, pierre, verre et dalles ; les feuilles
sont ignorées pour conserver les habitats naturels sous les arbres. Un abri
construit uniquement en feuilles conserve donc cette exception. Le support
et la position sont revérifiés tous les 20 ticks : un toit ajouté ensuite,
ou un villageois qui rentre avec une puce, fait retirer le spécimen naturel.
Le code utilise uniquement les chunks déjà chargés. Aucun bloc, biome,
générateur ou format de sauvegarde n’est modifié. Pas de déploiement personnel.
Vérifications réussies le 8 octobre 2026 sur Minecraft 26.3,
Fabric Loader 0.19.5, Fabric API 0.160.5+26.3 et Java 25 :
- `./gradlew check build assemblePack assembleTestPack :sanctuary-test:exportDuoLaunch
-PsanctuaryFocusedTests=insects245 -PsanctuaryAtlasOnly=true` réussit :
21 GameTests requis, compilation et contrôles de ressources passent.
Les nouveaux cas vérifient les supports herbe/pierre/sable/sol construit,
les berges d’eau douce, les canopées, les toits bois/verre/dalles, le vol
devant une entrée sans porte, les plafonds mixtes de population, la place
libérée après retrait et le cloporte dans la sélection ambiante. Aucun
chargement du chunk témoin éloigné n’est provoqué.
- La structure de test `sanctuary-gametest:insect_outdoors_251` mesure
24 × 12 × 24, avec `skyAccess=true` : les scènes sont réellement dehors,
au lieu du plafond invisible en barrières des anciennes structures.
Ce fichier NBT est une ressource du mod de tests, sans effet sur la génération.
- Client natif exclusivement Vulkan : apparition naturelle dans l’herbe,
refus d’une nouvelle apparition sous un toit de verre et retrait du
spécimen après pose du toit, reçus par les paquets réels. Déplacement et
fuite des sauteurs, capture debout silencieuse, 47 sprites FR/EN, neuf sons,
midi fixe et ciel dégagé passent également. Marqueur
`INSECTS251_CLIENT_PASS` dans `build/insects251/client-vulkan.log` ;
captures dans `build/insects251/screenshots/`.
- Les 47 textures et neuf sons sont identiques à beta.250. Tous les champs
du catalogue sont conservés, hormis les trois poids passés de 6 à 3 :
mois, heures et pics d’apparition restent inchangés.
- Exports [Sanctuary beta.251](../build/Sanctuary-beta.251.mrpack) et
[Sanctuary Test beta.251](../build/Sanctuary-Test-beta.251.mrpack) vérifiés :
intégrité ZIP, version, dépendances exactes et JAR embarqués identiques
aux JAR construits. [Reçu local](../build/insects251/validation.json).
La répartition visuelle sur une île complète reste à apprécier en jeu par
le créateur ; les essais vérifient les règles et les comportements dans
des scènes natives ciblées. L’ancienne visite beta.250 ne reçoit pas ces
changements automatiquement. Pas de publication du canal packwiz ni de
mise à jour de l’instance Prism personnelle.
+157
View File
@@ -0,0 +1,157 @@
# INSECT-02 — Carte d’inspection, beta.246
Demande du créateur du 8 octobre 2026 : ouvrir une instance jouable pour
observer les 47 insectes et faire des retours directs. Branche
`codex/insect-inspection-beta246`. Suit [INSECT-01](insects-wild-world-beta245.md).
## Carte jouable
Le module facultatif `sanctuary-test` prépare un monde plat neuf, graine 246,
dans `build/insects-246/<session>/server/insects-flat-246`. Il refuse tout autre
dossier ou générateur. Une plateforme éclairée en Y=80 comporte 47 parcelles
sur six colonnes, espacées de 14 blocs. Chaque insecte vivant est accompagné
de son numéro, son nom FR/EN, son identifiant et son habitat.
Fleurs adaptées, troncs de chêne ou jungle, bassin d’eau douce, neige,
lumière, nourriture déposée et villageois sont présents. La puce suit son
villageois dans un petit enclos. La pluie reste active pour l’escargot ;
l’éclairage est figé à midi avec vision nocturne pour regarder les sprites.
Le joueur entre en Aventure, filet fourni, avec les interactions normales.
Les saisons et l’expiration ambiante sont suspendues uniquement pour ces
47 entités transitoires ; supports, déplacements, approche, fuite et filet
utilisent le code de jeu. Un spécimen capturé ou enfui revient cinq secondes
après sa disparition. Deux coffres à l’entrée contiennent les 47 items pour
inspecter leurs icônes. Le laboratoire entretient uniquement ses spécimens,
sans apparitions aléatoires concurrentes. Les chunks de cette scène restent
chargés uniquement dans ce monde de développement.
| Action | Commande ou geste |
| --- | --- |
| Choisir une espèce | `/insectes 1` à `/insectes 47` |
| Suivante / précédente | `/insectes suivant` / `/insectes precedent` |
| Regarder sans effrayer | `/insectes observer` (spectateur) |
| Revenir au filet et à la fuite | `/insectes jouer` |
| Revenir à la première parcelle | `/insectes retour` |
| Réinitialiser les spécimens | `/insectes reset` |
| Capturer | S’accroupir, viser, clic droit |
`/insects` est un alias. En spectateur le moustique ne choisit pas le joueur
comme cible d’orbite ; revenir avec `jouer` pour observer son orbite autour
de soi. Aucun message de capture ou découverte n’est ajouté. Les numéros
suivent l’ordre du catalogue beta.245.
Le lancement crée un client de développement distinct, en Vulkan et FR,
et un serveur lié seulement à `127.0.0.1`, sur un port libre. Aucune instance
Prism, sauvegarde personnelle, canal public ou génération Sanctuary n’est
modifié. Les dossiers de scène et binaires sont ignorés par Git.
```sh
./gradlew :sanctuary-test:exportDuoLaunch
python3 scripts/insect_lab.py start --run review-03
```
Le nom de session doit être neuf ; `review-03` est la session ouverte pour
le créateur. Pour une autre carte neuve, utiliser par exemple `review-04`.
Pour rouvrir `review-03` après arrêt de ses processus :
```sh
python3 scripts/insect_lab.py server --run review-03
python3 scripts/insect_lab.py client --run review-03
```
PID et journaux sont enregistrés dans le dossier de session. Fermer le
client quitte la visite ; le serveur de développement reste disponible et
peut être arrêté proprement avec SIGTERM sur son PID.
## Textures capturées et filet
Les sprites sous `textures/insect/` étaient disponibles pour le rendu des
entités mais absents de l’atlas des modèles d’items. L’atlas Minecraft
`blocks` inclut désormais ce dossier. Les identifiants des spécimens et les
47 PNG fournis sont conservés. Le contrôle Vulkan vérifie le sprite du
modèle assemblé de chaque item, en plus de la présence du fichier PNG.
Le filet utilise une texture 2D **16 × 16**, avec le parent natif
`minecraft:item/handheld`, comme les outils Minecraft. Source transparente :
`mods/sanctuary/src/main/resources/assets/sanctuary/textures/item/insect_net.png`.
Création avec l’outil intégré de la compétence
[imagegen](/Users/koka/.codex/skills/.system/imagegen/SKILL.md), puis export
16 × 16 avec `sips`. Aucune texture d’insecte retouchée.
Prompt exact : [insect-net-beta246-prompt.txt](insect-net-beta246-prompt.txt).
## Vérifications
Le client de contrôle Vulkan réussit : 47 modèles d’items avec leur sprite
attendu, quatre modes de rendu des entités, capture unique avec usure,
découvertes silencieuses. Son journal ne contient plus de
`Missing textures in model sanctuary:item/insect_*`.
La carte émet `INSECTS246_LAB_POPULATION_PASS unique-species=47` après
validation des supports et création des spécimens. Le client de visite
contrôle aussi les 47 sprites d’inventaire et celui du filet, tous 16 × 16,
avant d’émettre `INSECTS246_CLIENT_OPEN`.
`check build assemblePack assembleTestPack` réussit sur les sources finales,
avec neuf GameTests requis. Client Vulkan de contrôle réussi et visite
`review-03` ouverte : connexion du joueur, formulaire fermé, position à la
première parcelle, 47 sprites d’items et filet 16 × 16 validés. Les 47 PNG
fournis gardent leurs empreintes ; 216 fichiers modifiés ou ajoutés avant ce
ticket restent identiques. Intégrité ZIP, versions et JAR embarqués vérifiés.
Livraisons locales : [pack normal](../build/Sanctuary-beta.246.mrpack) et
[pack Test](../build/Sanctuary-Test-beta.246.mrpack).
[Reçu et empreintes](../build/insects246/validation.json). Aucune publication
ni mise à jour Prism. Les comportements et l’équilibrage visuel restent à
juger avec les retours du créateur sur cette carte.
## Parcelles
| Numéro | Espèce | Identifiant |
| --- | --- | --- |
| 01 | Piéride du chou | `common_butterfly` |
| 02 | Citron | `yellow_butterfly` |
| 03 | Machaon | `tiger_butterfly` |
| 04 | Papillon paon | `peacock` |
| 05 | Monarque | `monarch` |
| 06 | Morpho bleu | `emperor` |
| 07 | Agrias | `agrias_butterfly` |
| 08 | Ornithoptère | `birdwing` |
| 09 | Papillon de nuit | `moth` |
| 10 | Grand paon de nuit | `oak_silk_moth` |
| 11 | Criquet long | `long_locust` |
| 12 | Criquet migrateur | `migratory_locust` |
| 13 | Mante religieuse | `mantis` |
| 14 | Mante orchidée | `orchid_mantis` |
| 15 | Cigale brune | `brown_cicada` |
| 16 | Cigale robuste | `robust_cicada` |
| 17 | Cigale cicadelle | `walker_cicada` |
| 18 | Cigale du soir | `evening_cicada` |
| 19 | Fulgore | `lantern_fly` |
| 20 | Libellule rouge | `red_dragonfly` |
| 21 | Libellule verte | `darner_dragonfly` |
| 22 | Libellule géante | `banded_dragonfly` |
| 23 | Fourmi | `ant` |
| 24 | Gerris | `pondskater` |
| 25 | Escargot | `snail` |
| 26 | Grillon | `cricket` |
| 27 | Grillon des prés | `bell_cricket` |
| 28 | Sauterelle | `grasshopper` |
| 29 | Courtilière | `mole_cricket` |
| 30 | Phasme | `walkingstick` |
| 31 | Coccinelle | `ladybug` |
| 32 | Cétoine | `fruit_beetle` |
| 33 | Scarabée | `scarab_beetle` |
| 34 | Bousier | `dung_beetle` |
| 35 | Scarabée Goliath | `goliath_beetle` |
| 36 | Capricorne | `longhorn_beetle` |
| 37 | Lucane scie | `saw_stag_beetle` |
| 38 | Lucane | `stag_beetle` |
| 39 | Lucane géant | `giant_beetle` |
| 40 | Lucane arc-en-ciel | `rainbow_stag` |
| 41 | Scarabée éléphant | `elephant_beetle` |
| 42 | Scarabée Hercule | `hercules_beetle` |
| 43 | Puce | `flea` |
| 44 | Cloporte | `pill_bug` |
| 45 | Moustique | `mosquito` |
| 46 | Mouche | `fly` |
| 47 | Cafard | `cockroach` |
+5
View File
@@ -0,0 +1,5 @@
Outil : image_gen intégré (compétence imagegen), fond transparent.
Export final : PNG transparent 16 × 16 ; modèle minecraft:item/handheld.
Prompt exact :
Use case: stylized-concept. Asset type: Minecraft inventory tool sprite, insect-catching net, transparent PNG. Create one flat 2D pixel-art bug net in the visual style and orientation of the vanilla Minecraft iron shovel item texture. A short brown wooden handle runs diagonally from bottom left to upper right; at its upper right end a light gray iron oval hoop and white string mesh, so it is clearly an insect-catching net rather than a shovel. EXACT tiny low resolution aesthetic: draw on a logical 16 by 16 pixel grid, every logical pixel a solid square color, upscale it evenly for the output. No more than 12 flat colors. Entire tool inside frame with one logical pixel margin. Dark brown outlines on handle, dark gray outlines on hoop, off-white sparse crisscross mesh with truly transparent open holes. Real transparent background outside tool and through holes. No backdrop, shadow, text, labels, lighting gradients, perspective, 3D model, anti-aliasing or checkerboard. This is the final game texture, not a mockup. Make the silhouette readable at 16px.
+89
View File
@@ -0,0 +1,89 @@
# Sons à préparer — Insectes Wild World
Demande du 8 octobre 2026. **Préparation seulement : aucun son ajouté en
beta.247.** Ces choix visent une ambiance lisible à l’échelle de Sanctuary ;
« silencieux » signifie sans son individuel dans le jeu, pas une affirmation
que l’espèce ne produit aucune vibration dans la nature.
Les neuf sons prioritaires sont intégrés en [beta.248](insect-cross-sounds-beta248.md),
avec [sources et licences](insect-audio-credits.md). Les deux sons facultatifs
restent à préparer. Le tableau ci-dessous conserve la direction artistique initiale.
## Fichiers utiles
Priorité : quatre chants de cigales, trois chants de grillons/courtilière,
une mouche en vol et un moustique en vol, soit **neuf sons**. Facultatifs :
un son de sauterelle et un son partagé par les deux criquets, soit deux autres.
Les descriptions sont des directions artistiques ; les noms Wild World
ne suffisent pas à identifier une espèce biologique précise.
Fournir des prises propres, sans musique ni fond de forêt déjà mélangé,
avec quelques variantes si disponibles. Un fichier mono OGG Vorbis convient
pour chaque son positionnel ; conserver également l’original d’enregistrement.
Pour les chants : quelques secondes avec des silences naturels. Pour les
bourdonnements : un petit segment raccordable sans clic. Le rythme, le volume,
l’atténuation et la programmation des déclenchements seront réglés après écoute.
Pas de chant de libellule ni de papillon dans cette première passe.
Références d’écoute : [Sounds of Insects, Smithsonian Folkways](https://folkways.si.edu/sounds-of-insects/album/smithsonian)
présente des grillons et des insectes en vol, dont moustiques et
mouches. [AMNH — cigales](https://www.amnh.org/explore/news-blogs/periodical-cicadas-overview)
décrit leurs chants. Ces références ne sont pas des fichiers livrés au jeu.
## Affectation des 47 espèces
| Parcelle | Espèce | Fichier à fournir | Intention |
| --- | --- | --- | --- |
| 01 | Piéride du chou | — | Silencieux |
| 02 | Citron | — | Silencieux |
| 03 | Machaon | — | Silencieux |
| 04 | Papillon paon | — | Silencieux |
| 05 | Monarque | — | Silencieux |
| 06 | Morpho bleu | — | Silencieux |
| 07 | Agrias | — | Silencieux |
| 08 | Ornithoptère | — | Silencieux |
| 09 | Papillon de nuit | — | Silencieux |
| 10 | Grand paon de nuit | — | Silencieux |
| 11 | Criquet long | locust.ogg (facultatif) | Frottement bref |
| 12 | Criquet migrateur | locust.ogg (facultatif) | Même famille que le criquet long |
| 13 | Mante religieuse | — | Silencieux |
| 14 | Mante orchidée | — | Silencieux |
| 15 | Cigale brune | cicada_brown.ogg | Crépitement rythmé |
| 16 | Cigale robuste | cicada_robust.ogg | Chant continu plus intense |
| 17 | Cigale cicadelle | cicada_walker.ogg | Chant pulsé |
| 18 | Cigale du soir | cicada_evening.ogg | Chant clair et espacé |
| 19 | Fulgore | — | Silencieux |
| 20 | Libellule rouge | — | Silencieux |
| 21 | Libellule verte | — | Silencieux |
| 22 | Libellule géante | — | Silencieux |
| 23 | Fourmi | — | Silencieux |
| 24 | Gerris | — | Silencieux |
| 25 | Escargot | — | Silencieux |
| 26 | Grillon | cricket.ogg | Chirps courts et répétés |
| 27 | Grillon des prés | bell_cricket.ogg | Trille clair, presque métallique |
| 28 | Sauterelle | grasshopper.ogg (facultatif) | Stridulation douce et espacée |
| 29 | Courtilière | mole_cricket.ogg | Trille sourd et continu |
| 30 | Phasme | — | Silencieux |
| 31 | Coccinelle | — | Silencieux |
| 32 | Cétoine | — | Silencieux |
| 33 | Scarabée | — | Silencieux |
| 34 | Bousier | — | Silencieux |
| 35 | Scarabée Goliath | — | Silencieux |
| 36 | Capricorne | — | Silencieux |
| 37 | Lucane scie | — | Silencieux |
| 38 | Lucane | — | Silencieux |
| 39 | Lucane géant | — | Silencieux |
| 40 | Lucane arc-en-ciel | — | Silencieux |
| 41 | Scarabée éléphant | — | Silencieux |
| 42 | Scarabée Hercule | — | Silencieux |
| 43 | Puce | — | Silencieux |
| 44 | Cloporte | — | Silencieux |
| 45 | Moustique | mosquito_flight.ogg | Bourdonnement aigu très proche |
| 46 | Mouche | fly_flight.ogg | Bourdonnement bref et irrégulier |
| 47 | Cafard | — | Silencieux |
Fourmi, escargot, puce et cloporte restent silencieux. Les déplacements des
scarabées et de la boule de neige pourront recevoir plus tard un frottement
très discret si leur lecture en jeu le demande. Le filet et la capture sont
des effets de geste séparés : souffle court du filet et prise douce éventuelle,
sans notification ni son de découverte. Rien de cela n’est implémenté ici.
+242
View File
@@ -0,0 +1,242 @@
{
"source": "Textures fournies par le créateur, Animal Crossing: Wild World / Nintendo, via Nookipedia",
"reference": "https://nookipedia.com/wiki/Bug/Wild_World",
"retrieved": "2026-10-08",
"assets": [
{
"id": "common_butterfly",
"file": "Common_Butterfly_WW_Inv_Icon.png",
"sha256": "0d1f1818a514406e38b0613d8d8e7be8272c95cc818cc3cfb0606b6caab75d6f"
},
{
"id": "yellow_butterfly",
"file": "Yellow_Butterfly_WW_Inv_Icon.png",
"sha256": "2f65e647c0bdfa0c791089a01679d4beff4187210735920cd989d7b00e7b3d49"
},
{
"id": "tiger_butterfly",
"file": "Tiger_Butterfly_WW_Inv_Icon.png",
"sha256": "3c057b5814ace6d23065b08b29fd4da9b5d7c063e2a666dc199dffa3a71bbcb0"
},
{
"id": "peacock",
"file": "Peacock_WW_Inv_Icon.png",
"sha256": "4465ac534609d3ccd912bd2a1e1f1fe54a74605d6cf015742ed82ee47bac215d"
},
{
"id": "monarch",
"file": "Monarch_WW_Inv_Icon.png",
"sha256": "0a6135be3c9fbb77422cd303634d1de6f90b77d4d58b83a41d154e7864efb6a8"
},
{
"id": "emperor",
"file": "Emperor_WW_Inv_Icon.png",
"sha256": "73c06c6b333a0fac5a4cc4ef2ea974d9ac24d61626c113d7a19cbc692aeccf28"
},
{
"id": "agrias_butterfly",
"file": "Agrias_Butterfly_WW_Inv_Icon.png",
"sha256": "7265cce5316036d08d3544f85678e426d073ee4e1ce6456509c9bbfd47691762"
},
{
"id": "birdwing",
"file": "Birdwing_WW_Inv_Icon.png",
"sha256": "341236d944d869c41f58a5229d3af62e22f45383c0e74830687024d250bdd676"
},
{
"id": "moth",
"file": "Moth_WW_Inv_Icon.png",
"sha256": "0da1cf7bc700b0a140346d88449855ff48868aaeb27b4331298399fafbd6a6b1"
},
{
"id": "oak_silk_moth",
"file": "Oak_Silk_Moth_WW_Inv_Icon.png",
"sha256": "b7048019ad492468712e73bb793e1fa0bf97da2af4d90ec2dea5214fb8ba9743"
},
{
"id": "long_locust",
"file": "Long_Locust_WW_Inv_Icon.png",
"sha256": "afa0056c19d5f590efc28f9fc48868996705ab26549659edbbacac2abd7aa485"
},
{
"id": "migratory_locust",
"file": "Migratory_Locust_WW_Inv_Icon.png",
"sha256": "f57515e1425d97f22ad3530f6fa53c05899461fb4b3f375f36f47b2596634116"
},
{
"id": "mantis",
"file": "Mantis_WW_Inv_Icon.png",
"sha256": "e1ec3c4543e173756a5ee87bc40d57958ba31a1ff7ade63558fd11fe16bef732"
},
{
"id": "orchid_mantis",
"file": "Orchid_Mantis_WW_Inv_Icon.png",
"sha256": "353c6d36c5a9757879a8c28f743d12fd16d1f5191ccf88f3580b926da08efccd"
},
{
"id": "brown_cicada",
"file": "Brown_Cicada_WW_Inv_Icon.png",
"sha256": "ec229a8b26a69a54bd597e8b1fe43855e0199beb4b0b34884e21a8f2b47239a2"
},
{
"id": "robust_cicada",
"file": "Robust_Cicada_WW_Inv_Icon.png",
"sha256": "7dd177071e5733e7f2ab4ab1004ac1bdd5f95110565cc52b5a53804cdda0a7d0"
},
{
"id": "walker_cicada",
"file": "Walker_Cicada_WW_Inv_Icon.png",
"sha256": "42f01a56c4d458c59f89daeacba1fd3424e3e84bbd8072bec150b491f236e058"
},
{
"id": "evening_cicada",
"file": "Evening_Cicada_WW_Inv_Icon.png",
"sha256": "e91c21b27a1188639fcf0427913284470472544f9d36ee8790fc042a281e789b"
},
{
"id": "lantern_fly",
"file": "Lantern_Fly_WW_Inv_Icon.png",
"sha256": "40c3f67c4b9fa71c278f1e27fc6b5d4d758a3f29162724a3d1ce375008e874ee"
},
{
"id": "red_dragonfly",
"file": "Red_Dragonfly_WW_Inv_Icon.png",
"sha256": "3e0ddba1988cc630132c4dda037d6377ca059186b1cc03384de19f4ba1783608"
},
{
"id": "darner_dragonfly",
"file": "Darner_Dragonfly_WW_Inv_Icon.png",
"sha256": "56bd05a9bdc0f292e9facfb837b980bfe8a218847fe339d5c4fc3184512aa01a"
},
{
"id": "banded_dragonfly",
"file": "Banded_Dragonfly_WW_Inv_Icon.png",
"sha256": "2d8bf8b73085f9af39584be1a28c36c901c14536adf34c1e8a34d86ee6f4bb5f"
},
{
"id": "ant",
"file": "Ant_WW_Inv_Icon.png",
"sha256": "5c25d6a3c7d7d60d2245ae153db19a79b2f1a566ee5fc3f518a02a5cf20e0403"
},
{
"id": "pondskater",
"file": "Pondskater_WW_Inv_Icon.png",
"sha256": "9602ce54f7a6a648bb1a4b713af95cd8548ae71c2e33944417e668a6ece388ec"
},
{
"id": "snail",
"file": "Snail_WW_Inv_Icon.png",
"sha256": "f8938dfd17aff67f1a7efb95c292f14d63eee1dfa6afc348c7351c62cb147e91"
},
{
"id": "cricket",
"file": "Cricket_WW_Inv_Icon.png",
"sha256": "b1fcd0b109a2a422917e39923e54e1490e7a0058fbf413ac232fc39b5c0536e8"
},
{
"id": "bell_cricket",
"file": "Bell_Cricket_WW_Inv_Icon.png",
"sha256": "ce92cb6539abc36acdf97312f4fcec53d87ed63abd195a3e7c391e94dd0a0a4f"
},
{
"id": "grasshopper",
"file": "Grasshopper_WW_Inv_Icon.png",
"sha256": "9328d7346601212faa8e3b763871d3f52db6fe290be2826475b0ed6decd6e0c7"
},
{
"id": "mole_cricket",
"file": "Mole_Cricket_WW_Inv_Icon.png",
"sha256": "21825104a8192a243b459d3dd1ad13e9a9ebd935e4b1a9dae4b7cdf942c25957"
},
{
"id": "walkingstick",
"file": "Walkingstick_WW_Inv_Icon.png",
"sha256": "fee425cb73673cd5b430e25e99b1144432b6ee430ab265b842c32bd4bb08c71e"
},
{
"id": "ladybug",
"file": "Ladybug_WW_Inv_Icon.png",
"sha256": "25a458cdce22389fcb3c1898ec94d940c5d62077d250dc18f2aa455f4852d4b6"
},
{
"id": "fruit_beetle",
"file": "Fruit_Beetle_WW_Inv_Icon.png",
"sha256": "a4da1eee334c442917ea1b94de6a91500dfbfda3964d17172e04b37f1fee886b"
},
{
"id": "scarab_beetle",
"file": "Scarab_Beetle_WW_Inv_Icon.png",
"sha256": "f67d7d74672bb76711819668636e9e65c0c8ba578a21a6e99a57e12c8985ba07"
},
{
"id": "dung_beetle",
"file": "Dung_Beetle_WW_Inv_Icon.png",
"sha256": "6767a7c0df6b8cfd9ae8f0dbc31a6d8401ed878d7f3adf9d2daa50eba381e5f4"
},
{
"id": "goliath_beetle",
"file": "Goliath_Beetle_WW_Inv_Icon.png",
"sha256": "9776c48cedd7ccb4f124fc8d9a481ff851cf8171889784357051d502021c73a2"
},
{
"id": "longhorn_beetle",
"file": "Longhorn_Beetle_WW_Inv_Icon.png",
"sha256": "2a14437a6fc00b0c06a612fec8eeae9a2ded853eeecb72f97c248efed62ca51a"
},
{
"id": "saw_stag_beetle",
"file": "Saw_Stag_Beetle_WW_Inv_Icon.png",
"sha256": "a92dbee2c9659cdc725501068c34fedfbb7fabdb9ff0a5880d62c63905e8db20"
},
{
"id": "stag_beetle",
"file": "Stag_Beetle_WW_Inv_Icon.png",
"sha256": "7696ac7da4342ec1d720b42ee9fcdbb0f8897ecf5e913c0e2361224c8dd6a02b"
},
{
"id": "giant_beetle",
"file": "Giant_Beetle_WW_Inv_Icon.png",
"sha256": "6b3148ed720a53038d1ad5054870b57bf79fe63140196f4a8d06caf894179469"
},
{
"id": "rainbow_stag",
"file": "Rainbow_Stag_WW_Inv_Icon.png",
"sha256": "1cdff97582f9221b97f6bb46dd4804dc61b8787c7e98b422c49895ae7847d4c3"
},
{
"id": "elephant_beetle",
"file": "Elephant_Beetle_WW_Inv_Icon.png",
"sha256": "67061bb466f0c16ea2596505760775153a11db8b11dc7e2c3424de09b4d2bfe6"
},
{
"id": "hercules_beetle",
"file": "Hercules_Beetle_WW_Inv_Icon.png",
"sha256": "817612b4722dbda095ee4329e12e8cb94684fcd17b8640db3e7cb0cf43836051"
},
{
"id": "flea",
"file": "Flea_WW_Inv_Icon.png",
"sha256": "a68dc39161a5c425438ff568f748f03418851728206174376987f471dfd962ba"
},
{
"id": "pill_bug",
"file": "Pill_Bug_WW_Inv_Icon.png",
"sha256": "0a9b3cf87295dfdd97b568b2522bb7a25a9e03b371d48d7abe14fe02105faa41"
},
{
"id": "mosquito",
"file": "Mosquito_WW_Inv_Icon.png",
"sha256": "d1ddce1a0db8e60c636be2953e56143b3c7b943df5318440c7c3ed0efa8dacf2"
},
{
"id": "fly",
"file": "Fly_WW_Inv_Icon.png",
"sha256": "eaf5c25ce867dd6bfc23ea3e46c64473f2aa4e3ba9e25cb2fe35edb96db81c01"
},
{
"id": "cockroach",
"file": "Cockroach_WW_Inv_Icon.png",
"sha256": "82906ce0072b7c4f8cf7fc1821adf07ad185a46552965425db2f82cf9a5e8426"
}
]
}
+196
View File
@@ -0,0 +1,196 @@
# INSECT-01 — Insectes Wild World et filet, beta.245
Branche : `codex/insects-wild-world-beta245`. Demande du créateur du
8 octobre 2026 ; périmètre confirmé : **les 47 textures fournies**, parmi
les 56 espèces de [Wild World](https://nookipedia.com/wiki/Bug/Wild_World).
Les neuf espèces sans texture ne sont pas enregistrées.
## Résultat implémenté
47 espèces ambiantes, non agressives, avec habitat, mois, heures et pics
côté serveur. Le calendrier utilise la date civile et le fuseau de
`RealtimeService`, comme Sanctuary ; hémisphère sud : décalage de six mois.
Les créneaux sont début inclus et fin exclue, y compris ceux qui traversent
minuit. Les horaires spécifiques à certains mois restent distincts.
Les pics multiplient le poids d’apparition par **3**, dans le créneau
disponible ; ils ne forcent aucune apparition et ne permettent pas
d’apparition hors saison. Poids de base 6, ou 2 pour les espèces rares
du catalogue ; équilibrage Sanctuary, sans reprise des prix en clochettes.
Chaque sprite fourni est copié sans retouche. Les papillons, la mouche et le papillon
de nuit utilisent deux quads articulés au centre et deux moitiés d’UV.
Les insectes des troncs utilisent un quad vertical fixé à la face de bûche.
Fourmi, gerris, mantes et coccinelle : quad horizontal. Criquets, grillons,
escargot et autres petits insectes : billboard vertical ; les billboards
aériens suivent aussi l’inclinaison de la caméra. Le moustique tourne
autour du joueur vivant le plus proche sans jamais l’attaquer.
Approche normale : fuite à moins de 3 blocs ; sprint : 5 blocs ;
accroupi : 0,65 bloc. Le moustique tolère une approche normale jusqu’à
0,95 bloc, ce qui permet son orbite à 1,8 bloc (sprint : 2,5 blocs).
Une ligne de vue libre est nécessaire. Une attaque effraie l’insecte.
La fuite dure 24 ticks puis l’entité disparaît ; elle n’est plus capturable.
Les insectes ne poussent ni ne blessent et ne produisent aucun butin de mort.
Le filet `sanctuary:insect_net` s’utilise au **clic droit**, avec une visée
à 3 blocs. Le serveur choisit le premier insecte sur le trajet du geste,
respecte les obstacles et produit exactement un spécimen. Délai entre
gestes : 10 ticks ; durabilité : 128, un point par geste en survie.
Inventaire plein : dépôt protégé aux pieds du joueur. Capture et découverte
du filet, de sa recette et des 47 spécimens **sans notification**, à la demande
du créateur ; les compteurs et découvertes restent enregistrés en silence. Les spécimens
`sanctuary:insect_<espèce>` utilisent les mêmes sprites et ont des noms FR/EN.
Le filet et les spécimens figurent aussi dans Outils et utilitaires en créatif.
Recette, `S` = fil, `#` = bâton :
```text
SS
#S
#
```
## Habitats et adaptations explicites
- Papillons près des fleurs ; machaon près des fleurs rouges/roses, paon
près des fleurs bleues/violettes/noires. Mante orchidée : fleurs blanches.
- Mantes, coccinelle et escargot peuvent aussi être sur des feuilles ;
l’escargot exige une pluie effective sur son support, sans neige ni toit.
- Espèces de palmiers sur bûches/bûches de bois de jungle non écorcées.
Pas de nouvelle essence d’arbre. Les autres espèces utilisent les bûches.
- Gerris sur eau douce source avec air au-dessus, à la hauteur native
de sa surface ; biomes océaniques exclus.
- Fourmis près de chair putréfiée déposée ; mouches aussi près d’os ou de
pommes de terre empoisonnées déposées. Pas de système de pourrissement.
- Bousier sur neige, sans création ou poussée de boule de neige.
- Puce sur un villageois vivant qu’elle suit. Papillon de nuit près
d’un bloc lumineux à ciel ouvert.
- Cloporte : chance de sortie en frappant une roche ; courtilière :
chance de sortie après avoir creusé un bloc de terre. Au plus une
tentative par joueur toutes les cinq secondes, probabilité de 1/3.
Pas de population souterraine invisible ni de chant souterrain.
La colonne des pics Nookipedia de l’escargot contient un second créneau
`4 AM – 7 PM ()` sans mois. Seul le pic explicite de mai, 16 h–8 h,
est repris ; aucun mois n’est inventé pour ce fragment incomplet.
## Monde et conservation
Apparitions uniquement dans l’Overworld d’une partie Sanctuary prise en
charge. Un ancien monde ordinaire n’est pas inscrit par la seule installation
du mod. Tous les supports et déplacements restent dans des chunks chargés ;
aucun chunk, bloc ou profil de génération n’est créé ou modifié par ce système.
Échantillonnage toutes les cinq secondes, 18 tentatives bornées par joueur,
au plus deux ajouts ; plafond de 32 entités dans un rayon local de 32 blocs
et espacement d’un bloc entre sites occupés. Durée de vie maximale :
120 secondes. Saison/horaire et support revérifiés chaque seconde.
Les entités ambiantes sont transitoires et non sauvegardées. Les objets
capturés suivent la sauvegarde native des items. Aucun format existant,
identifiant existant, sauvegarde personnelle ou génération n’est migré.
Les modifications beta.242–244 préexistantes sont conservées.
## Catalogue
Mois numérotés de 1 à 12, référence nord ; horaires de la source.
Le catalogue runtime est `data/sanctuary/insects/wild_world.json` ;
la provenance des textures est [inventoriée](insects-assets.json).
| Espèce | Habitat Sanctuary | Mois | Horaires | Pics |
| --- | --- | --- | --- | --- |
| Piéride du chou (`common_butterfly`) | FLOWERS | 3,4,5,6,7,8,9 | 4 AM – 7 PM (Mar – Jun) / 8 AM – 5 PM (Jul – Aug) / 8 AM – 4 PM (Sep) | 8 AM – 5 PM (Mar – Jun) |
| Citron (`yellow_butterfly`) | FLOWERS | 3,4,5,6,7,8,9 | 4 AM – 7 PM (Mar – Jun) / 8 AM – 5 PM (Jul – Aug) / 8 AM – 4 PM (Sep) | 8 AM – 5 PM (Mar – Jun) |
| Machaon (`tiger_butterfly`) | FLOWERS | 3,4,5,6,7,8,9 | 4 AM – 7 PM (Mar – Jun) / 8 AM – 5 PM (Jul – Aug) / 8 AM – 4 PM (Sep) | 8 AM – 5 PM (Mar – Jun) |
| Papillon paon (`peacock`) | FLOWERS | 3,4,5,6,7,8,9 | 4 AM – 7 PM (Mar – Jun) / 8 AM – 5 PM (Jul – Aug) / 8 AM – 4 PM (Sep) | 8 AM – 5 PM (May – Jun) |
| Monarque (`monarch`) | FLOWERS | 9,10,11 | 4 AM – 5 PM (Sep – Oct) / 8 AM – 5 PM (Nov) | 8 AM – 5 PM (Oct – Nov) |
| Morpho bleu (`emperor`) | FLOWERS | 6,7,8,9 | 8 AM – 5 PM | — |
| Agrias (`agrias_butterfly`) | FLOWERS | 6,7,8,9 | 8 AM – 5 PM | — |
| Ornithoptère (`birdwing`) | FLOWERS | 6,7,8,9 | 8 AM – 5 PM | — |
| Papillon de nuit (`moth`) | LIGHT | 5,6,7,8,9 | 7 PM – 4 AM | 11 PM – 4 AM (Jun – Aug) |
| Grand paon de nuit (`oak_silk_moth`) | TREE | 6,7,8,9 | 7 PM – 4 AM | — |
| Criquet long (`long_locust`) | GROUND | 8,9,10,11 | 8 AM – 7 PM | Sep – Nov |
| Criquet migrateur (`migratory_locust`) | GROUND | 9,10,11 | 8 AM – 7 PM | 8 AM – 4 PM |
| Mante religieuse (`mantis`) | FLOWER | 8,9,10,11 | 8 AM – 5 PM | 8 AM – 4 PM (Sep – Nov) |
| Mante orchidée (`orchid_mantis`) | WHITE_FLOWER | 8,9,10,11 | 8 AM – 5 PM | 8 AM – 4 PM (Sep – Nov) |
| Cigale brune (`brown_cicada`) | TREE | 7,8 | 8 AM – 5 PM | 8 AM – 4 PM |
| Cigale robuste (`robust_cicada`) | TREE | 7,8 | 8 AM – 5 PM | 8 AM – 4 PM |
| Cigale cicadelle (`walker_cicada`) | TREE | 7,8,9 | 8 AM – 5 PM | Aug |
| Cigale du soir (`evening_cicada`) | TREE | 7,8 | 4 AM – 8 AM; 4 PM – 7 PM | — |
| Fulgore (`lantern_fly`) | TREE | 6,7,8,9 | 4 PM – 7 PM; 11 PM – 8 AM | — |
| Libellule rouge (`red_dragonfly`) | AIR | 9,10 | 8 AM – 7 PM | 4 PM – 5 PM |
| Libellule verte (`darner_dragonfly`) | AIR | 6,7,8 | 8 AM – 5 PM | 4 PM – 5 PM |
| Libellule géante (`banded_dragonfly`) | AIR | 7,8 | 8 AM – 5 PM | — |
| Fourmi (`ant`) | ROTTEN_FOOD | 1,2,3,4,5,6,7,8,9,10,11,12 | All day | — |
| Gerris (`pondskater`) | WATER | 6,7,8,9 | 8 AM – 7 PM | — |
| Escargot (`snail`) | RAIN_FLOWER | 4,5,6,7,8,9 | All day | 4 PM – 8 AM (May) / 4 AM – 7 PM () |
| Grillon (`cricket`) | GROUND | 9,10,11 | 5 PM – 8 AM | — |
| Grillon des prés (`bell_cricket`) | GROUND | 9,10 | 5 PM – 8 AM | — |
| Sauterelle (`grasshopper`) | GROUND | 7,8,9 | 4 PM – 5 PM (Jul) / 8 AM – 5 PM (Aug – Sep) | Sep |
| Courtilière (`mole_cricket`) | UNDERGROUND | 1,2,3,4,5,11,12 | All day | Dec – Feb |
| Phasme (`walkingstick`) | TREE | 7,8,9,10,11 | 4 AM – 7 PM | — |
| Coccinelle (`ladybug`) | FLOWER | 3,4,5,6,10 | 8 AM – 5 PM | Mar – May |
| Cétoine (`fruit_beetle`) | TREE | 7,8,9 | All day (Jul – Aug) / 7 PM – 8 AM (Sep) | 7 PM – 4 AM (Jul – Aug) |
| Scarabée (`scarab_beetle`) | TREE | 7,8 | 11 PM – 8 AM | — |
| Bousier (`dung_beetle`) | SNOW | 1,2,12 | 5 PM – 8 AM | — |
| Scarabée Goliath (`goliath_beetle`) | PALM_TREE | 6,7,8 | 7 PM – 8 AM | — |
| Capricorne (`longhorn_beetle`) | TREE | 6,7,8 | 8 AM – 7 PM (Jun) / 8 AM – 5 PM (Jul – Aug) | — |
| Lucane scie (`saw_stag_beetle`) | TREE | 7,8 | All day | 11 PM – 8 AM |
| Lucane (`stag_beetle`) | TREE | 6,7,8 | 7 PM – 8 AM | 11 PM – 8 AM (Jul – Aug) |
| Lucane géant (`giant_beetle`) | TREE | 7,8 | 11 PM – 8 AM | — |
| Lucane arc-en-ciel (`rainbow_stag`) | TREE | 6,7,8,9 | 7 PM – 8 AM | — |
| Scarabée éléphant (`elephant_beetle`) | PALM_TREE | 7,8 | 7 PM – 8 AM | — |
| Scarabée Hercule (`hercules_beetle`) | PALM_TREE | 7,8 | 11 PM – 8 AM | — |
| Puce (`flea`) | VILLAGER | 3,4,5,6,7,8,9,10,11 | All day | — |
| Cloporte (`pill_bug`) | ROCK | 1,2,3,4,5,6,7,8,9,10,11,12 | All day | Nov – Mar |
| Moustique (`mosquito`) | AIR | 6,7,8,9 | 5 PM – 4 AM | 5 PM – 7 PM |
| Mouche (`fly`) | ROTTEN_FOOD | 1,2,3,4,5,6,7,8,9,10,11,12 | All day | — |
| Cafard (`cockroach`) | TREE | 1,2,3,4,5,6,7,8,9,10,11,12 | All day | — |
## Vérifications
Le lancement serveur ciblé utilise le profil plat déjà prévu pour les tests
fonctionnels (`sanctuaryAtlasOnly`), afin d’éviter les prérequis worldgen.
```sh
./gradlew check build assemblePack -PsanctuaryFocusedTests=insects245 -PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryInsects245ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
```
Les neuf GameTests requis sont réussis : calendrier et pics, recette réelle,
fleurs, pluie, eau, faces de troncs, approche accroupie, fuite, capture unique,
obstacles, portée, inventaire plein, spectateur et absence de chargement
forcé. Les 47 PNG 16 × 16 correspondent aux SHA-256 des fichiers fournis ;
modèles d’items et noms FR/EN présents.
Le parcours client Vulkan réussit sur Apple M1 / MoltenVK 1.4.2 : chargement
des 47 textures et noms FR/EN, quatre modes de rendu, pluie effective pour
l’escargot, orbite du moustique et capture native au clic droit accroupi
avec un point d’usure. Les découvertes sont enregistrées ; ni le filet,
ni sa recette, ni les spécimens ne déclenchent de notification. Une
découverte ordinaire reste annoncée. Marqueur du test :
`INSECTS245_CLIENT_PASS ... silent-discoveries=pass`.
Captures de contrôle :
[surfaces](../mods/sanctuary/build/run/clientGameTest/screenshots/0000_insects245-day-surfaces.png),
[filet](../mods/sanctuary/build/run/clientGameTest/screenshots/0001_insects245-net-capture.png),
[moustique](../mods/sanctuary/build/run/clientGameTest/screenshots/0002_insects245-mosquito-night.png).
Le monde plat de contrôle reste sombre ; ces captures servent à vérifier
les formes et interactions, pas à valider l’éclairage d’une île Sanctuary.
L’équilibrage des fréquences reste à apprécier dans une partie normale.
Les essais restent dans des mondes de développement neufs. Aucune
publication, instance Prism ou sauvegarde personnelle modifiée.
`check build assemblePack` réussit sur les sources finales ; le pack exporté
contient le même JAR beta.245, les 47 textures originales, les modèles,
le catalogue et la recette. Intégrité ZIP et version Minecraft 26.3 /
Fabric Loader 0.19.5 vérifiées. Les 57 fichiers préexistants hors points
d’intégration conservent leur empreinte initiale.
Livraison locale : [Sanctuary-beta.245.mrpack](../build/Sanctuary-beta.245.mrpack)
(12 977 936 octets), SHA-256
`aa537808cb458813f366ffc9d67dd9a3e1fc60cbd1ce5e8ddc8b1ab40cb0ac46`.
JAR beta.245 : SHA-256
`94132d1bd854259110d49d77f58a7bbaeed94f73a50df92460ef46fbf2355444`.
[Reçu de validation](../build/insects245/validation.json).
+60
View File
@@ -0,0 +1,60 @@
# INSECT-06 — Sauterelles et criquets en déplacement, beta.250
Retour du créateur le 8 octobre 2026 : les sauterelles sautaient sur place ;
les bonds doivent les déplacer, y compris durant leur fuite.
Branche `codex/jumping-insects-beta250`.
Espèces concernées : sauterelle, criquet long et criquet migrateur.
Le serveur choisit un nouveau point au sol pour chaque bond et déplace
l’insecte en arc vers ce point. Les bonds ordinaires durent 20 ticks,
parcourent 0,9 à 1,4 bloc et culminent à 0,65 bloc, avec une pause de 35 à
65 ticks entre les sauts. Le vagabondage reste à 2,5 blocs du point d’apparition.
La fuite interrompt le bond en cours sans téléporter l’insecte : un arc
part de sa position courante vers une destination à 1,6 bloc dans la direction
opposée au joueur. Les bonds durent 8 ticks, s’enchaînent sans pause et
l’insecte disparaît après les 24 ticks habituels de fuite.
Avant un bond, chaque segment de l’arc doit rester dans des chunks chargés
et éviter les collisions ; l’arrivée doit avoir un support terrestre valide
et sec, au même niveau ou à un bloc de différence. Le support est actualisé
à l’atterrissage. Un obstacle peut faire choisir une autre direction ; aucun
chunk ni bloc n’est créé par ce déplacement. Les autres espèces conservent
leurs comportements, captures silencieuses et sons existants.
Vérifications réussies le 8 octobre 2026 :
- `./gradlew check build assemblePack assembleTestPack :sanctuary-test:exportDuoLaunch
-PsanctuaryFocusedTests=insects245 -PsanctuaryAtlasOnly=true` : 16 GameTests
requis et les contrôles de compilation/ressources réussis. Les trois espèces
parcourent un arc, atterrissent, restent immobiles entre deux bonds et
vagabondent sur un support valide. Deux atterrissages de fuite en 16 ticks,
interruption en plein saut sans téléportation, murs fermés et refus de
l’eau vérifiés dans le moteur réel.
- Client natif exclusivement Vulkan : déplacement horizontal et vertical
reçu par les paquets du serveur, puis fuite déclenchée en plein bond et
disparition. Captures silencieuses au filet debout, 47 sprites d’inventaire,
traductions FR/EN, neuf fichiers audio décodés, midi et ciel dégagé passent
également. Journal `build/insects250/client-vulkan.log`, marqueur
`INSECTS250_CLIENT_PASS` ; captures dans `build/insects250/screenshots/`.
- Exports [Sanctuary beta.250](../build/Sanctuary-beta.250.mrpack) et
[Sanctuary Test beta.250](../build/Sanctuary-Test-beta.250.mrpack) : ZIP,
version, Minecraft 26.3/Fabric Loader 0.19.5 et JAR embarqués vérifiés.
Le dossier `/Users/koka/Downloads/Sanctuary-textures-beta.249/` reste
disponible ; les 47 textures et les neuf sons conservent leurs hashes.
Il n’y a pas de modification du format de sauvegarde ni de la génération.
Les essais utilisent des mondes de développement jetables ; la publication
et l’instance Prism personnelle ne sont pas mises à jour.
Pour la visite jouable : `/insectes 11` (criquet long), `/insectes 12`
(criquet migrateur), `/insectes 28` (sauterelle). `/insectes observer`
permet de regarder sans effrayer ; `/insectes jouer` remet le filet et
la réaction à l’approche. L’appréciation de la cadence reste à faire par
le créateur dans cette visite.
Visite `review-08` ouverte en beta.250 sur un nouveau monde plat graine 246,
disposition d’inspection version 2, midi fixe et ciel dégagé. Les 47 espèces
et sprites sont vérifiés à l’ouverture ; le lancement conserve une copie
du runtime pour rester indépendant d’une reconstruction Gradle ultérieure.
[Reçu local](../build/insects250/validation.json).
+74
View File
@@ -0,0 +1,74 @@
# INSECT-05 — Coccinelle billboard et textures à remplacer, beta.249
Demande du créateur le 8 octobre 2026, après inspection de beta.248.
Branche `codex/ladybug-texture-export-beta249`.
La coccinelle utilise le rendu billboard vertical orienté vers la caméra.
Son habitat sur fleur, son sprite, ses saisons et son comportement demeurent
identiques. Le point d’appui utilise maintenant le contour natif de la fleur
ou des feuilles + 0,005 bloc, partagé par le laboratoire. Le rendu compense
les deux lignes transparentes sous les pattes. Sans ce recalage, le nouveau
quad vertical flottait au-dessus de la fleur. Le catalogue change uniquement
`ladybug.shape`, de `FLAT` vers `BILLBOARD`, sans nouveau mode de rendu
ni changement de sauvegarde.
Un export local rassemble les images statiques du client Minecraft 26.3,
des mods du pack et du resource pack Sanctuary actif. Il conserve les
chemins `assets/<namespace>/...`, dimensions et fichiers `.mcmeta` associés.
Le dossier de travail regroupe les versions visibles dans le jeu ; les
sources conservent les variantes masquées par un remplacement de resource pack.
L’inventaire CSV/JSON indique source, chemin, dimensions et empreinte.
Les fichiers générés, JAR et dépendances ne sont pas ajoutés au dépôt.
Dossier local : `/Users/koka/Downloads/Sanctuary-textures-beta.249/`.
Le sous-dossier `a-modifier/` est un resource pack Minecraft 26.3 au format
97.1 prêt à accueillir les dessins. Il contient 4 671 images visibles et
245 fichiers `.mcmeta` ; `sources/` conserve les 4 810 images sources avant
remplacements. Dix icônes du client sont aussi copiées séparément.
Les 47 insectes sont dans `a-modifier/assets/sanctuary/textures/insect/`.
L’index d’assets Minecraft est également lu et ses SHA-1 vérifiés : panorama,
images Realms et icônes. PNG et métadonnées sont copiés sans retouche ;
chaque SHA-256 est vérifié avant de rendre le dossier disponible.
Le dossier existe séparément du dépôt et l’export refuse d’écraser un dossier
existant. L’archive `Sanctuary-textures-beta.249.zip` est vérifiée.
Les skins, cartes et textures générées à l’exécution ne sont pas des images
statiques à exporter. Les anciennes archives non chargées ne sont pas utilisées.
Reproduction depuis le lancement Gradle exporté :
```sh
python3 scripts/export_textures.py --output /chemin/vers/un/nouveau-dossier
```
Le script utilise le JAR du client exact, les ressources des mods du classpath
et le pack Sanctuary actif. L’export de cette livraison utilise
`build/duo/client.args`, Minecraft 26.3 et Sanctuary beta.249.
Les notices et provenances connues sont copiées, sans attribuer au créateur
la propriété des images reçues.
## Vérifications
`./gradlew check build assemblePack assembleTestPack` réussit avec
`-PsanctuaryFocusedTests=insects245 -PsanctuaryAtlasOnly=true` : 13 GameTests
requis passent. Le contrôle client Vulkan natif passe après correction
du point d’appui : coccinelle sur le contour natif de sa fleur, trois angles
de caméra rendus et examinés, 47 sprites et traductions FR/EN, cinq modes
de rendu, capture debout silencieuse, midi/ciel clair, neuf sons décodés
et arrêt du bourdonnement après fuite.
Logs : `build/insects249/check-build-pack-final.log`,
`build/insects249/client-vulkan-final.log`. Captures finales dans
`build/insects249/screenshots/`. Le premier rendu révélait la hauteur
ancienne de 0,9 bloc et est conservé dans `screenshots-before-contact/`.
La visite beta.248 a gardé ses fichiers figés pendant les builds ;
la visite `review-07` est ouverte en beta.249, midi et ciel clair,
47 espèces et sprites vérifiés. `/insectes 31` choisit la coccinelle.
Son lancement utilise une copie figée des classes et ressources.
Packs locaux : [Sanctuary](../build/Sanctuary-beta.249.mrpack) et
[Sanctuary Test](../build/Sanctuary-Test-beta.249.mrpack). ZIP, versions et JAR
embarqués vérifiés. Publication et instance Prism ne sont pas utilisées.
[Reçu final et empreintes](../build/insects249/validation.json).
245 des 253 fichiers préexistants sont identiques ; les 8 autres sont
étendus par ce ticket. Aucun fichier préexistant supprimé.
+109
View File
@@ -0,0 +1,109 @@
# MAP-244 — Carte Habitant et chunks reçus
Branche `codex/map-chunk-sync-beta244`, base locale beta.243, Minecraft 26.3.
Demande du 7 octobre 2026 ; livraison locale beta.244.
## Contrat
La vue Habitant doit relever les chunks effectivement envoyés au joueur par
Minecraft, à sa distance de vue, et conserver ces découvertes personnelles.
Le vol créatif ne doit plus laisser de trous à cause d'un relevé tournant dans
un rayon de 32 blocs. Aucun chargement supplémentaire, partage des relevés,
changement de génération, format de sauvegarde ou monde personnel.
Les six coupes d'altitude restent disponibles, avant et après l'achat Atlas.
La vue Opérateur demeure une inspection distincte.
La carte ouverte et la carte Pause reçoivent les changements du viewport
avec leur jeton courant ; permissions, dimension et annulation restent
contrôlées côté serveur. Les noms et positions distantes se renouvellent
toutes les cinq secondes. La carte reste une projection de dessus des chunks
reçus, sans calcul de visibilité optique ou d'occlusion.
## Vérification et limites
Le parcours natif Vulkan passe en **1 min 41 s** sur MoltenVK 1.4.2 / Apple M1 :
déplacement créatif de **320 blocs en 80 ticks**, correspondance entre les
chunks réellement présents côté client et les six relevés personnels serveur,
absence de pixels inconnus dans ces chunks, mise à jour de la carte ouverte
sans nouveau jeton, placement d'un bloc d'or actualisé par delta, paquets
obsolètes rejetés, Pause FR/EN avec changements du réglage GUI 2/3/4, sauvegarde/relecture
du schéma existant et révocation d'Atlas. À distance de vue **32**, **3 003
chunks reçus** sont contrôlés ; il ne s'agit pas d'une prégénération intégrale.
Sur **128 chunks plats**, après échauffement, le calcul des **six couches**
mesure **0,045 ms en moyenne**, **0,046 ms au 95e percentile**, par chunk.
Cette mesure porte sur le calcul de couleurs, sans les coûts de sauvegarde,
réseau ou rendu ; elle ne promet pas le même résultat sur l'île Sanctuary,
un gain de FPS, ni une capacité multijoueur donnée.
```sh
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryMap244ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=map244,map,waystones239 -PsanctuaryAtlasOnly=true
cd build/packwiz
packwiz modrinth export --output ../Sanctuary-beta.244.mrpack
```
Log client : `build/map244-client-vulkan.log`, marqueur `MAP244_CLIENT_PASS`.
**`check build assemblePack` passe en 2 min 48 s**, avec les contrôles purs
du dépôt et **14 GameTests requis** : deux tests MAP-244, deux tests Atlas
existants, les neuf tests Waystones / positions des joueurs et le framework.
Les nouveaux tests contrôlent les six relevés complets au-delà de l'ancien
rayon, avant achat Atlas, leur séparation personnelle, l'absence de réécriture
sur livraison identique, l'exclusion des spectateurs et l'absence de chargement
distant. Log : `build/map244-check-build-pack.log`. Une seconde mesure sur
56 chunks du serveur **plat** donne 0,091 ms en moyenne et 0,120 ms au 95e
percentile ; elle ne constitue pas un benchmark du terrain Sanctuary.
## Implémentation
Le point d'envoi natif `PlayerChunkSender.sendChunk` relève le chunk FULL
déjà envoyé au joueur. `dropChunk` retire seulement sa présence dans la vue,
sans effacer les découvertes. Les chunks reçus avant la création de l'habitant
restent identifiés pour un relevé ultérieur. Les spectateurs ne découvrent pas
de terrain ; les cartes des autres habitants ne sont jamais copiées.
Un cache partagé pendant un tick évite de recalculer les six couches lorsque
plusieurs habitants reçoivent le même chunk. Les constructions visibles
invalident ce cache et entrent dans une file dédupliquée, prioritaire sur les
revisites. Ces dernières et les changements de blocs disposent de **16 chunks
maximum et 2 ms par tick serveur**, avec au plus quatre chunks par habitant ;
un calcul de chunk commencé ne peut pas être interrompu. L'ordre des habitants
tourne pour répartir ce budget. Le relevé d'un chunk nouvellement envoyé est
synchrone avec l'envoi natif et s'ajoute à son coût, hors budget de revisite.
Une requête garde son abonnement jusqu'à la fermeture, un déplacement de
viewport, un changement de couche ou une perte d'autorisation. Seules les
tuiles changées sont poussées, avant le fond historique restant à transmettre.
Un lot personnel est plafonné à **64 chunks** (anciennement 16), soit environ
17 Kio de couleurs et coordonnées ; Demeure garde son lot apparié. Les paquets
identiques ne salissent ni la sauvegarde ni la texture Atlas. Les marqueurs
et positions distantes gardent une cadence de cinq secondes. Une relecture
complète de secours se fait après une minute, plutôt que toutes les cinq
secondes ; l'inspection Opérateur conserve sa cadence précédente.
## Limites et distribution
La limite existante de **16 384 chunks par couche et habitant** est conservée :
une carte pleine ne peut plus ajouter de terrain et signale cet état. Les
relevés préexistants et leur masque sont conservés ; revisiter un chunk reçu
complète son relevé sans migration. Aucun terrain déjà visité mais absent des
anciens relevés n'est reconstitué automatiquement. La fenêtre graphique garde
ses 64 × 64 chunks, son zoom et ses contrôles ; les relevés au-delà restent
consultables en déplaçant la carte. Le changement de plafond garde son jeton
indépendant et les six fichiers existants.
Tests effectués dans des mondes de développement neufs. Aucun monde personnel,
canal packwiz, instance Prism ou publication distante modifié. Les changements
préexistants beta.242/243 sont conservés ; 49 fichiers de ces chantiers hors
fichiers partagés sont vérifiés identiques à l'état de départ.
[MRpack local beta.244](../build/Sanctuary-beta.244.mrpack) vérifié :
**12 918 991 octets**, ZIP intègre, Minecraft 26.3 / Fabric Loader 0.19.5,
un seul JAR Sanctuary, identique au build testé. Les deux mixins de suivi et
les visages beta.243 sont présents ; aucun GameTest ni monde n'est embarqué.
Mod, pack et manifeste alignés. Reçu : `build/map244-validation.json`.
- SHA-256 JAR : `0ec7866df185601e493d0ee94588c552b906e76b16c061a2b52217a02cfe8b03`.
- SHA-256 MRpack : `3c224ea6256a574ca200affbcfb40a3b8174cc5f5cc8aac220c96b46fa37e3aa`.
+103
View File
@@ -0,0 +1,103 @@
# MAP-243 — Visages des joueurs au premier plan
Branche `codex/map-player-heads-beta243`, base locale beta.242,
Minecraft **26.3**, livraison locale **beta.243**.
Demande du 7 octobre 2026.
## Contrat
Dans l’Atlas et dans la carte du menu Pause, afficher les joueurs par le
visage de leur skin, avec sa seconde couche. Dessiner les visages après le
terrain, les propriétés, la grille, les waystones et les annonces suivies.
Conserver un repère de direction pour le joueur local et afficher le nom
au survol. Les autres joueurs de la même dimension sont localisés par le
serveur, y compris au-delà de la distance de suivi des entités ; cette
information ne découvre aucun terrain. L’accès reste soumis à l’aptitude Atlas.
Les waystones appartenant à un ascenseur ne figurent plus sur aucune des
deux cartes. Utiliser l’index serveur complet pour identifier une colonne,
y compris lorsque les autres étages sont inconnus ou hors de la coupe
d’altitude. Les téléporteurs isolés découverts restent visibles. Après
suppression du dernier autre étage, la pierre isolée redevient visible.
Les voyages, noms, découvertes et sauvegardes restent conservés.
## Positions et rendu
Paquet transitoire `sanctuary:atlas_players_v1` : jeton de carte, UUID, nom,
positions X/Z précises. Le serveur exige l’accès Atlas, filtre la dimension
et le viewport de 1024 blocs, exclut les autres spectateurs et plafonne
chaque réponse à 1024 joueurs. Les positions sont renouvelées avec les
demandes de carte, toutes les 100 ticks client après la fin du flux de
terrain, soit environ cinq secondes en fonctionnement normal. Les entités
déjà suivies et le joueur local utilisent leur position client actuelle.
Le joueur local est dessiné en dernier entre les visages superposés.
Le rendu utilise le visage natif Minecraft 26.3 (`PlayerFaceExtractor`),
12 pixels GUI, seconde couche incluse. Le skin provient de l’entité ou du
`PlayerInfo` reçu du serveur ; le skin natif par défaut sert pendant son
chargement. Une nouvelle couche GUI place les visages après les autres
repères. Le nom au survol a priorité ; une annonce couverte par un visage
ne reçoit pas le clic. La couleur personnelle reste dans le cadre du
joueur local, avec son repère de direction.
Les données sont propres à l’écran et à son jeton. Un paquet obsolète ou
reçu sans carte lisible est ignoré ; une perte d’autorisation ou un changement
de dimension vide les positions. Aucun nouveau fichier de sauvegarde ni
nouveau libellé fixe n’est ajouté. Les interfaces FR/EN existantes sont
conservées.
## Vérification
Le parcours client **Vulkan / MoltenVK 1.4.2 / Apple M1** passe en **38 s** :
FR/EN, échelles GUI 2/3/4, deux cartes, paquets natifs, joueur en ligne sans
entité côté client, skin de son `PlayerInfo`, UV du visage et de sa seconde
couche. L’ordre du rendu natif et les captures vérifient le visage au-dessus
d’une waystone horizontale et d’une annonce placées au même endroit.
Les jetons obsolètes, la déconnexion et la révocation d’Atlas sont contrôlés.
Le serveur masque un ascenseur dont seul le rez-de-chaussée est découvert.
```sh
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryMap243ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=waystones239 -PsanctuaryAtlasOnly=true
```
Log client : `build/map243-client-vulkan.log`, marqueur `MAP243_CLIENT_PASS`.
Captures inspectées : [Atlas FR GUI 3](../build/map243-screenshots/atlas-fr_fr-3.png),
[Pause FR GUI 3](../build/map243-screenshots/pause-fr_fr-3.png),
[Atlas EN GUI 4](../build/map243-screenshots/atlas-en_us-4.png) et
[Pause EN GUI 4](../build/map243-screenshots/pause-en_us-4.png).
**`check build assemblePack` passe en 2 min 43 s**, avec les contrôles purs
du dépôt et **dix GameTests requis** : les huit scénarios Waystones, le
nouveau scénario de positions des joueurs et le test du framework.
L’index, les découvertes et les noms survivent à leur codec existant ; un
étage inconnu ou au-dessus de la coupe masque toute la colonne, puis sa
suppression fait réapparaître le téléporteur isolé. Le scénario des joueurs
contrôle l’accès, le viewport, les dimensions, les spectateurs, la
désactivation d’Atlas et le codec réseau. Log :
`build/map243-check-build-pack.log`.
[MRpack local beta.243](../build/Sanctuary-beta.243.mrpack) vérifié :
**12 914 930 octets**, ZIP intègre, Minecraft 26.3 / Fabric Loader 0.19.5,
un seul JAR Sanctuary, identique au build final. Les classes de la couche
de visages et du nouveau paquet sont présentes ; aucun GameTest ni monde
n’est embarqué. Mod, pack et manifeste alignés sur beta.243. Les fichiers
du chantier beta.242 présents avant le ticket sont conservés ; seuls ses
quatre fichiers partagés (README, versions et configuration de tests)
reçoivent les ajouts de ce ticket. Reçu : `build/map243-validation.json`.
- SHA-256 JAR : `497ecdba2603863561af11caa4f6be8d23b4bea12de22078d2f815f557fca1b9`.
- SHA-256 MRpack : `1c1ce34650da5eba60112d3a03e67aa48ef77e1e8867b40122c6ab9c68918308`.
## Limites et distribution
La fixture multijoueur ajoute un joueur serveur simulé dans la liste en
ligne et son `PlayerInfo` au vrai client ; deux clients distants avec latence
réelle et un serveur de 1024 joueurs ne sont pas testés. Les joueurs hors
du viewport ou d’une autre dimension ne figurent pas sur la carte.
La mise à jour des positions distantes suit la cadence décrite ci-dessus.
Livraison locale ; publication, canal packwiz et instance Prism personnelle
non modifiés. Génération, chunks et formats existants conservés.
+66
View File
@@ -0,0 +1,66 @@
# AUDIO-253 — musique du menu Sanctuary
Branche `codex/title-music-beta253`, à partir de beta.252 ; Minecraft **26.3**,
livraison **beta.253**. Sound Physics Remastered et les habitats de beta.251
restent présents.
## Résultat
Le fichier `sanctuary-menu.m4a` fourni le 8 octobre 2026 est converti en
`assets/sanctuary/sounds/music/menu.ogg` : **Vorbis, stéréo, 48 kHz**, qualité 6
avec libvorbis 1.3.7. Aucun filtre de volume, fondu, découpage ou rééchantillonnage
n'est appliqué. Le nombre de trames audio décodées est conservé :
7 929 792, soit **165,204 secondes** ; le conteneur M4A annonçait
165,248 secondes. Taille de l'OGG : **2 962 073 octets**.
L'événement `sanctuary:music.menu` lit cette ressource en streaming sur le canal
**Musique**. Il remplace la musique de menu native et démarre dès le premier
tick musical au menu titre, après le chargement des ressources. Les curseurs
Musique et volume principal restent ceux de Minecraft. Le morceau continue
dans les Options et les autres sous-menus ; revenir au titre ne le redémarre
pas. Il recommence lorsqu'il se termine.
Le remplacement porte uniquement sur la situation musicale `Musics.MENU`.
Les écrans ayant leur propre musique et les situations de jeu gardent leur
choix musical. L'introduction du portail arrête la piste de menu comme elle
arrêtait auparavant la musique native. Le nom affiché du morceau est
**Sanctuary — Menu**, avec libellé FR/EN.
Le M4A original sur le Bureau n'est pas modifié ni copié dans le dépôt.
L'OGG et `licenses/Menu-Music-ASSET.json` sont embarqués dans le mod et son
template de ressources exportable. Aucun mod supplémentaire n'est requis.
Génération, sauvegardes et installations personnelles inchangées.
## Vérifications
Vérifications réussies le 8 octobre 2026, Java 25, Fabric Loader 0.19.5 et
Fabric API 0.160.5+26.3 :
- Conversion et décodage complet avec FFmpeg : Vorbis stéréo 48 kHz,
7 929 792 trames avant et après conversion, original M4A inchangé.
- `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuaryMenuMusic253ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan`
réussit : décodeur Vorbis natif, canal Musique actif, bonne ressource en
streaming, démarrage avant le délai natif de cinq secondes, même flux conservé
dans les Options et au retour au titre, nom du morceau FR/EN, reprise après
rechargement des ressources et après arrêt simulant la fin de la piste,
introduction silencieuse vis-à-vis de la musique du menu et choix musical
explicite d'un écran conservé. Marqueur `MENU253_CLIENT_PASS`.
- `./gradlew check build assemblePack assembleTestPack
-PsanctuaryFocusedTests=menus -PsanctuaryAtlasOnly=true` réussit : 138 tâches,
quatre GameTests serveur requis et contrôles du dépôt. La suite historique
complète des GameTests n'est pas revendiquée.
- JAR, deux MRpacks et template de ressources : intégrité ZIP, versions,
dépendances, classes musicales finales, fichier OGG et empreintes vérifiés.
Sound Physics Remastered et l'écologie beta.251 restent embarqués.
Reçus et logs ignorés : `build/music253/conversion.json`,
`build/music253/client-vulkan.log`, `build/music253/check-build-pack.log`,
`build/music253/validation.json`. Aucun canal public ou jeu personnel modifié.
Artefacts locaux :
- `build/Sanctuary-beta.253.mrpack`, 16 475 098 octets,
SHA-256 `31f77959b6fdd5fbbae86c83f31863593f8b0a4caea9ad397c7ab83fad3df2a3`.
- `build/Sanctuary-Test-beta.253.mrpack`, 16 888 896 octets,
SHA-256 `f3d8d2430fbdea261509394e9b0ffc6d1801a6c89b33c2033c4b11c07e66fb52`.
+18 -4
View File
@@ -1,5 +1,17 @@
# Distribution packwiz et Prism
## beta.257 — Mods client séparés
JEI Sanctuary, Sound Physics Remastered et Sodium sont facultatifs et séparés
à partir de cette livraison. [Contrat et limites](external-mods-beta257.md).
`assemblePack` produit deux JAR locaux ; `publish_pack.py` vérifie et joint
Sanctuary **et le fork JEI** avant de publier leurs métadonnées immuables.
La commande de préparation sans publication est désormais
`python3 scripts/pack.py release <sanctuary-https-url> <jei-https-url>`.
Les trois entrées du canal sont facultatives, activées par défaut côté client.
Modifier le choix via packwiz-installer pour qu'il persiste au lancement suivant.
Le pack local reste décochable dans Prism ; le fork JEI local est un override.
## Rattrapage des releases du 24 septembre 2026
Les binaires conservés des beta.145–151 et beta.154–166 sont publiés dans Gitea.
@@ -2066,16 +2078,18 @@ La procédure complète, également utilisable manuellement :
1. Incrémenter ensemble les versions de `gradle.properties` et `packwiz/pack.toml`
selon la [convention `beta.xxx`](versioning.md).
2. Exécuter `./gradlew check build assemblePack` et les vérifications du ticket.
3. Préparer les métadonnées de la livraison, avec l'URL exacte du futur JAR :
3. Préparer les métadonnées de la livraison, avec les deux URL exactes :
```sh
python3 scripts/pack.py release https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.001/sanctuary-beta.001.jar
python3 scripts/pack.py release \
https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.257/sanctuary-beta.257.jar \
https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.257/jei-sanctuary-26.3-30.32.0-sanctuary.4.jar
```
4. Pousser le commit source vérifié. Créer une release Gitea correspondant à ce
commit et joindre le JAR construit, sans remplacer les artefacts d'une release
commit et joindre les JAR Sanctuary et JEI construits, sans remplacer les artefacts d'une release
antérieure. Vérifier que le téléchargement public possède le SHA-256 indiqué
dans `build/packwiz-release/mods/sanctuary.pw.toml`.
dans `build/packwiz-release/mods/sanctuary.pw.toml` et `jei-sanctuary.pw.toml`.
5. Publier le contenu de `build/packwiz-release/` à la racine de la branche
`packwiz` dans un seul commit, puis vérifier les fichiers et hashes depuis
l'URL publique. Préserver l'historique de ce canal et ne jamais y copier le
+157
View File
@@ -0,0 +1,157 @@
# PAUSE-236 — Atlas plein écran derrière le menu
## Statut : essai annulé le 6 octobre 2026
Le créateur demande d'annuler **uniquement la carte derrière Pause**.
Le patch de disposition `5ea7950` est inversé, sans réécrire l'historique :
carte centrale et colonne Communauté → Aventure → Game à gauche lorsque
l'Atlas est déverrouillé, comme en beta.235. Story, la disposition sans Atlas
et le raccourci M (`94fefc0`) restent présents. Le Nord retrouve sa position
dans le panneau de carte. Aucun changement sur les autres branches.
Les vérifications et la livraison ci-dessous décrivent l'essai historique,
avant annulation. Son pack est déplacé dans
`build/withdrawn/Sanctuary-beta.236.mrpack` pour ne pas le proposer comme résultat
actuel ; le staging packwiz de cet essai et son JAR d'origine sont également
archivés dans `build/withdrawn/`. Le numéro beta.236 était également utilisé par la branche des aptitudes,
qui a depuis atteint beta.238 avec Forgerie : cette branche d'essai ne constitue
pas la livraison commune. Aucun nouvel artefact de distribution n'est livré
par l'annulation ; la consolidation et son prochain compteur global restent
un travail distinct.
Le parcours client Vulkan du retour réussit en 46 s : FR/EN GUI 2/3/4, géométrie
beta.235 avec/sans Atlas, Story et ses 100 entrées, recherche, filtres, retours,
focus et redimensionnement. M depuis le jeu et Pause, verrou de progression,
réaffectation et désactivation passent également. Log :
`build/pause-map-layer-cancel-client-vulkan.log`.
Comparaison Git : les deux fichiers de disposition/carte et leurs assertions
Pause/Solo sont identiques à beta.235 ; implémentation du raccourci et libellés
FR/EN identiques au commit `94fefc0`.
`./gradlew check build -PsanctuaryFocusedTests=menus -PsanctuaryAtlasOnly=true`
réussit en 2 min 34 s : quatre GameTests requis et contrôles purs du dépôt.
Log : `build/pause-map-layer-cancel-check-build.log`. Aucun nouvel assemblage
de distribution : il s'agit de l'annulation ciblée des sources de l'essai.
## Historique de l'essai
Branche d'essai `codex/pause-map-layer-beta236`, base beta.235, commit `00d175c`,
Minecraft 26.3. Le rendu reste à apprécier par le créateur.
## Résultat
Gazette reste à gauche et Notice Board à droite, sur toute la hauteur du corps
de Pause, avec ou sans Atlas. Au centre, Aventure (Discovery, Progression,
Story) et Communauté (Habitant, Faction, Battle) sont côte à côte ; Partie /
Game suit juste dessous. Reprendre la partie garde le pied de page natif.
Avec Atlas, la carte occupe littéralement tout l'écran sur le calque derrière
les contrôles. Le terrain est légèrement assombri pour lire les libellés. Le
Nord `N ↑` se trouve au centre horizontal, juste sous le bandeau du message
serveur ; un espace réservé le sépare du menu. Les boutons et espacements
s'adaptent aux petites hauteurs.
La carte est rendue avant les widgets de premier plan, puis reçoit les entrées
après eux. Un clic dans une zone libre ouvre l'Atlas complet ; les boutons,
les fils, l'en-tête et le pied de page gardent leur priorité. Les infobulles
et les marqueurs de la carte ne capturent pas le survol derrière un contrôle.
Les marqueurs suivis restent accessibles lorsqu'ils sont exposés. Tab après
Quit atteint la carte ; Entrée ouvre l'Atlas, Échap ou Terminé revient à la
même Pause. Redimensionner reconstruit le calque aux dimensions de l'écran.
Seul le terrain personnel déjà cartographié est affiché. Ce changement ne
révèle ni ne génère de nouveaux chunks. Les contrats serveur, sauvegardes,
progression, données communautaires et contenus Story restent identiques.
Les libellés FR/EN existants sont réutilisés pour la disposition.
## Raccourci carte
La lettre **M** ouvre l'Atlas complet depuis le jeu et depuis Pause, une fois
la carte déverrouillée dans Progression. Avant ce déblocage, elle affiche le
message existant rappelant cette condition. Depuis le jeu, Échap et Terminé
reviennent au jeu ; depuis Pause, ils reviennent à la même Pause.
Le raccourci figure dans les contrôles natifs, catégorie Sanctuary, sous
« Ouvrir la carte » / « Open world map ». Il suit la lettre M de la disposition
du clavier, y compris AZERTY, et préserve une affectation sauvegardée ou
désactivée. Les écrans de saisie conservent leurs touches. Aucun nouveau
protocole, fichier persistant ou déblocage serveur n'est ajouté.
## Réversibilité
Le code de l'essai et ses assertions sont isolés du raccourci M et du commit de
livraison contenant les versions et la documentation : `git revert 5ea7950` annule le
calque et sa disposition. Après ajout du raccourci (`94fefc0`), l'application
inverse du patch a été vérifiée avec `git apply --reverse --3way --cached`
dans un index temporaire : aucun conflit et raccourci M conservé, sans modifier
les fichiers de travail. La branche
`codex/pause-columns-beta235` conserve la variante précédente. Annuler le
commit de disposition ramène à cette variante ; une livraison suivante prend
le prochain numéro beta, sans diminuer le compteur.
## Vérifications
```sh
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true -PsanctuaryStory234ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true -PsanctuaryPause233ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
./gradlew check build assemblePack -PsanctuaryFocusedTests=menus -PsanctuaryAtlasOnly=true
```
Parcours Story et raccourci réussi en 42 s (`STORY234_PASS`) : Vulkan FR/EN GUI 2/3/4,
fenêtre 1280 × 960, avec et sans Atlas, puis redimensionnement à 1344 × 1008.
Nord centré sous le message serveur, terrain réellement reçu, calque aux
dimensions de l'écran, boutons et titres des fils prioritaires pour les clics
et le survol. Clic natif sur une zone libre et Entrée ouvrent l'Atlas ; Échap
et Terminé reviennent à la même Pause. Le clic natif sur Story traverse bien
le premier plan, ses 100 entrées, recherche et filtres restent disponibles.
Saisie continue, focus, défilement, retours et progression serveur conservés.
Événements clavier natifs M depuis le jeu et Pause avant/après déblocage,
réaffectation à J, suppression de l'ancienne affectation, désactivation et
restauration de la touche testées. La saisie de M dans Story garde le texte,
le focus et son écran ; les intitulés du raccourci sont vérifiés en FR/EN.
Log : `build/pause236-story-client-vulkan-final.log`.
21 captures copiées dans `build/pause236-story-screenshots/`. Captures inspectées :
avec Atlas FR/2 et EN/4, sans Atlas FR/2. La fixture du monde plat neuf
cartographie explicitement 25 chunks : la majorité du fond reste inconnue.
Ce terrain limité ne représente pas une sauvegarde personnelle explorée.
Aux GUI étroits, les longs libellés natifs et titres des fils gardent leur
défilement ou découpage ; les actions Sanctuary restent lisibles en entier.
Validation macOS/Vulkan uniquement, aucun essai OpenGL ou Windows revendiqué.
Le parcours Pause final réussit en 42 s (`PAUSE233_PASS`), avec les mêmes
langues, GUI et états Atlas. Les fils restent de part et d'autre du menu ;
publication, photo, retours des sélecteurs, brouillons et ancienne catégorie
sont conservés. Inventaire réel et fichier communautaire restent inchangés
pendant les lectures ; configuration temporaire restaurée à la fin.
Log : `build/pause236-client-vulkan-final.log` ; 38 captures copiées dans
`build/pause236-screenshots/`. Captures inspectées : Pause avec Atlas et posts
FR/2 et EN/4. Vulkan confirmé : MoltenVK 1.4.2, Apple M1.
Le contrôle `check build assemblePack` réussit en 2 min 31 s : quatre GameTests
requis et contrôles purs du dépôt, dont fichier communautaire, photos,
demandes et recherche. Log : `build/pause236-check-build-pack-final.log`.
La sélection menus/Atlas reste celle du banc existant ; le conflit du générateur
sur le chemin natif par défaut, documenté en beta.232, reste hors de ce ticket.
La compilation des sources finales du banc client passe également.
## Livraison locale
`mod_version`, `pack_version` et `packwiz/pack.toml` sont alignés sur beta.236.
Les commits `5ea7950` (disposition) et `94fefc0` (raccourci) restent séparés
des métadonnées de livraison.
Export historique [Sanctuary-beta.236.mrpack](../build/withdrawn/Sanctuary-beta.236.mrpack),
12 779 209 octets : intégrité ZIP, Minecraft 26.3 et Fabric Loader 0.19.5,
un seul JAR Sanctuary embarqué, identique au JAR construit, sans monde ni module
de test. Les 3 303 fichiers d'assets, données et resourcepacks sont identiques
à beta.235, sauf un nouveau libellé de raccourci dans chaque langue ; toutes
les traductions existantes sont conservées. Story reste dans le JAR de production
et les classes des bancs client en sont absentes.
Reçu : `build/pause236-mrpack-receipt.json`.
- JAR : `74a8be9a078e9ecdc17b8cf332254cdb4a6dca4c37e4fb728d98632d2e8c3918`.
- MRpack : `0fac46765a4430df8f90398471dedd2c2636d6b120482cc78466011fdd7574c2`.
Aucune publication distante, modification du canal packwiz, synchronisation
Prism ou ouverture d'un monde personnel effectuée.
+223
View File
@@ -0,0 +1,223 @@
# SHADER-254 — Budget Vulkan et profils de shader
Socle beta.253 (`833f14d`), livraison locale beta.254, Minecraft 26.3,
Java 25, branche `codex/vulkan-shader-optimization`. Demande du 8 octobre
2026 : réduire le coût des ombres, sun shafts, minerais émissifs, SSGI et
SSR. Le créateur demande explicitement le SSR dans le profil Moyen.
Les pertes de 150–300 FPS signalées motivent la passe ; elles ne constituent
pas une mesure instrumentée du résultat.
## Résultat
**Options → Shaders → Profil** propose Patate, Faible, Moyen, Élevé, Ultra
et Personnalisé, en FR/EN. Le shader est nommé **Ambiance** dans le menu
et ses infobulles, en français comme en anglais. Sélectionner un profil applique
ses effets, intensités, distances et budgets. Modifier un réglage visuel passe en
Personnalisé en conservant le budget du dernier profil. L'interrupteur général,
l'eau naturelle et les torches chaudes gardent leurs préférences séparées.
Une installation neuve prépare Moyen, avec SSR, PBR et SSGI, mais conserve
l'activation volontaire du shader. Les préférences déjà enregistrées restent
Personnalisé avec le budget de référence ; aucune sélection automatique d'un
profil ne remplace les choix explicites de l'utilisateur. Ce budget de référence
conserve les anciens plafonds d'ombres/GI/SSR et borne désormais l'émission
à 256 blocs. Les migrations
PBR/eau antérieures restent appliquées selon leur contrat existant.
| Profil | Ombres : portée / carte maximale / profondeur par pixel | Sun shafts : segments × échantillons | SSGI : résolution / rayons × étapes | PBR et SSR : portée / recherche SSR maximale | Émission : portée |
| --- | --- | --- | --- | --- | --- |
| Patate | OFF | OFF | OFF | OFF | OFF |
| Faible | 32 blocs / 1024² / 4 lectures | OFF | OFF | OFF | 32 blocs |
| Moyen | 64 blocs / 2048² / 4 lectures | 8 × 1 | ¼ de chaque dimension / 6 × 6 | 32 blocs / 12 étapes | 32 blocs |
| Élevé | 128 blocs / 2048² / 16 lectures | 12 × 4 | ½ de chaque dimension / 8 × 8 | 64 blocs / 16 étapes | 64 blocs |
| Ultra | 256 blocs / 4096² / 16 lectures | 24 × 4 | ½ de chaque dimension / 12 × 10 | 128 blocs / 32 étapes | 128 blocs |
Moyen conserve les ombres, les reflets du paysage et du soleil sur l'eau,
le relief PBR, une lumière indirecte à 25 %, des sun shafts à 25 %, un bloom
à 20 %, les minerais à 50 %, les lumières colorées et les contours subtils.
Les distances s'atténuent avant leur coupure. Faible conserve les ombres,
l'émission/bloom et les lumières colorées ; Patate conserve la colorimétrie
avec les effets lourds désactivés.
## Changements de rendu
- La carte de profondeur commune aux ombres et au volume solaire est
conservée tant que les casteurs, l'ancre de caméra de 16 blocs, la portée,
la lune/le soleil et la direction lumineuse restent valides. Un déplacement
du soleil supérieur à un quart de texel sur les bornes conservatrices impose
un redessin. La projection stockée reste celle de la carte effectivement
dessinée ; les uniformes de caméra sont actualisés à chaque image.
- Le plafond de résolution, le filtre des ombres, les segments du volume,
la résolution/rayons/étapes du SSGI et la recherche SSR suivent le budget
sélectionné. Le SSR garde ses raffinements d'intersection et sa validation
de profondeur. Les effets n'utilisent aucune accumulation temporelle.
- Le maillage des émetteurs rejette les sections hors portée avant analyse,
avec une garde couvrant les coins. Les fragments s'atténuent dans les
derniers 20 % de la portée. Cela limite aussi les halos des sources natives ;
la lumière de jeu et les niveaux d'éclairage du serveur restent inchangés.
La transmission des feuilles reste procédurale. Elle ne correspond pas
exactement à l'alpha des textures ; ce ticket ne livre pas d'ombres de feuilles
fidèles au pixel. Le SSGI réduit et le SSR borné peuvent perdre de petits
détails, particulièrement hors écran ou sur de fines silhouettes.
## Validation et mesures
La compilation et `check build assemblePack` finaux passent sur macOS avec Java 25
(137 tâches, 2 min 46) :
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=menus -PsanctuaryAtlasOnly=true
```
Les quatre GameTests serveur ciblés, contrôles purs et manifestes du pack
passent. Le premier lancement sans `sanctuaryAtlasOnly=true` utilise une
configuration de test incompatible avec le générateur attendu et échoue par
`ClassCastException` ; aucune modification worldgen n'est introduite pour ce
chantier graphique.
Les essais natifs utilisent exclusivement Vulkan sur Windows 10 Pro,
Ryzen 7 3700X, RX 9070 XT, pilote Vulkan `1.4.315 AMD proprietary driver
25.10.30 (LLPC)`, Java 25.0.1. La référence et le candidat sont exécutés
successivement dans des dossiers de développement distincts, via la session
interactive Windows. Aucun monde ou réglage de l'instance Prism personnelle
n'est utilisé.
La scène de mesure est un monde plat neuf, graine 122, zone 49 × 49 avec sol
blanc, minerais d'or, feuilles persistantes et eau, caméra fixe, six chunks,
1920 × 1080, VSync et plafond FPS désactivés. Chaque effet est chauffé avant
une collecte de 100 ticks ; deux balayages inversent l'ordre des effets.
Une variante avance le soleil et remet l'heure à 3000 avant chaque collecte.
Le chronomètre entoure `GameRenderer.renderFrame` : il mesure le temps CPU
de rendu/soumission et les attentes rencontrées, **pas une durée GPU isolée ni
la durée totale de la boucle de jeu**. Les médianes ne se convertissent pas
fiablement en FPS réels et les deltas des effets ne s'additionnent pas.
Les mesures statiques avant l'ajout du SSR Moyen sont conservées comme
historique ; la validation finale de ce profil utilise le SSR activé.
Les profils définitifs passent sur le PC : activation volontaire, anciennes
préférences conservées, persistance des cinq budgets et du mode Personnalisé,
allocation/libération des effets, plafond des cartes, cache réutilisé à caméra
fixe puis invalidé après édition, déplacement de 16 blocs et changement de
l'heure. Le SSR Moyen produit un reflet dépendant de la couleur du paysage :
signal rouge 2,365 contre 0,173 avec la source bleue, avec une intensité de
20 %. Les contrôles d'ombres/contours et les suites bloom/minerais/lens flare
passent : masques pour 20 textures de minerais, émission et halo indépendants,
occlusion par murs, sources natives, éditions, libération des ressources,
rechargement et redimensionnement. Le SSGI passe les rebonds rouge/bleu,
l'occlusion, la stabilité, un balayage
latéral de 12 blocs et les gros plans à sept distances et intensités 35/100.
Le PBR passe les normales, textures mises en cache, matériaux mats, relief,
petits mouvements, occlusion par toit, rechargement/redimensionnement et quatre transferts de
dimension. Le volume solaire passe les salles fermées, ouvertures, feuilles,
soleil hors
écran, retour de caméra, proximité des murs, rechargement/redimensionnement et libération de la
carte partagée. La météo passe les transitions ciel clair/brume/pluie/orage,
brouillard natif et restauration du réglage manuel. La suite SSR passe l'eau,
le verre transparent, les surfaces solides,
les couleurs des sources, les occlusions, les intersections, les déplacements,
OFF/zéro et rechargement/redimensionnement. Les contrôles graphiques des effets touchés sont
validés. Les temps de rendu et les captures finales sont également collectés.
Les captures initiales ont aussi révélé des libellés répétés dans les choix
Profil, mélange de l'eau et distances PBR/SSR. Le format des valeurs est
corrigé ; les aides précisent maintenant les distances du profil Moyen.
| Configuration statique | beta.253 (ms) | beta.254 (ms) |
| --- | ---: | ---: |
| Effets OFF | 0.395 | 0.409 |
| Ombres 128 blocs | 0.794 | 0.435 |
| Sun shafts 128 blocs | 0.816 | 0.404 |
| Bloom | 0.460 | 0.465 |
| Minerais | 0.454 | 0.398 |
| Bloom + minerais | 0.471 | 0.477 |
| SSGI, budget de référence | 0.806 | 0.795 |
| PBR + SSR, budget de référence | 0.682 | 0.672 |
| Tous les effets, réglages de référence | 2.192 | 1.918 |
Moyenne des médianes des deux balayages (OFF : quatre collectes). Les
ombres 128 baissent de 45 %, le volume 128 de 51 % et l'ensemble de 12,5 %.
Le SSGI et le SSR au budget de référence évoluent peu ; le gain des profils
vient aussi des résolutions, rayons, étapes et portées plus faibles. Les
écarts de quelques centièmes de milliseconde sur les effets légers restent
dans la variabilité visible des collectes OFF (0,371–0,441 ms côté candidat).
| Profil beta.254 | Médiane statique (ms) | P95 (ms) |
| --- | ---: | ---: |
| Patate | 0.373 | 0.507 |
| Faible | 0.536 | 0.658 |
| Moyen, SSR ON | 1.562 | 1.715 |
| Élevé | 1.793 | 1.947 |
| Ultra | 1.989 | 2.194 |
Chaque profil a une collecte de 100 ticks après chauffe. Moyen réduit
le temps mesuré de 28,7 % par rapport à l'ancien ensemble, avec des choix
visuels différents : portée d'ombres 64 au lieu de 128, PBR/SSR 32 au lieu
de 64 et budgets GI/volume réduits. Le SSR reste activé. Ces pourcentages
ne sont pas des gains de FPS mesurés dans un monde Sanctuary réel.
| Soleil mobile, même scène | beta.253 (ms) | beta.254 (ms) |
| --- | ---: | ---: |
| Effets OFF | 0.388 | 0.335 |
| Ombres 128 blocs | 0.804 | 0.374 |
| Sun shafts 128 blocs | 0.801 | 0.335 |
| Tous les effets, réglages de référence | 2.435 | 1.885 |
Moyen avec SSR mesure 1.520 ms (P95 1.756 ms),
soit 37.6 % de moins que l'ancien ensemble sur cette variante.
Le mouvement continu du soleil invalide périodiquement le cache ; son gain
ne dépend donc pas uniquement d'une heure figée. Les transitions fréquentes
entre ancres/chunks, les grands mondes, les autres pilotes et un parcours
de jeu normal n'ont pas encore de comparaison avant/après dans ce ticket.
Les données brutes, médianes par balayage et P95 sont conservées dans
`build/shader254/{baseline,candidate-final,baseline-moving,candidate-moving}-performance.json`
et `build/shader254/comparison.json`.
Les captures finales FR/EN sont relues : « Profil : Moyen » / « Profile: Medium »,
avec les valeurs de mélange d'eau et de distances sans libellé répété.
Le dernier test de profils passe avec le menu corrigé : « Profil : Moyen »
et « Ambiance : Oui » en FR, « Profile: Medium » et « Ambiance: ON » en EN. Reçu :
`build/shader254/validation.json`.
## Reproduction
Le socle commun des tests natifs est :
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryPixelShadows122ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientNoVsync=true \
-PsanctuaryQuickTests=true
```
Ajouter `-PsanctuaryShaderProfiles254ClientTests=true` pour les profils,
persistance, migration et cache, `-PsanctuaryShaderPerformance254=true` pour
les temps de rendu, et `-PsanctuaryShaderMovingSun254=true` pour la variante
avec soleil mobile. Les suites du volume/météo et du SSR utilisent
respectivement `-PsanctuarySunshaft137ClientTests=true` et
`-PsanctuarySsrClientTests=true`. Les suites ciblées du bloom, du SSGI et du PBR
utilisent `-PsanctuaryBloom130ClientTests=true`,
`-PsanctuarySsgi132ClientTests=true` et `-PsanctuaryPbr134ClientTests=true`.
La suite historique a été arrêtée d'abord sur les anciens défauts 128/32
blocs, puis parce que le test bloom effaçait les préférences sans réactiver
le shader. Ses attentes sont alignées sur Moyen et sa préparation active
explicitement le shader. Les vérifications restantes sont lancées par effet,
sans désactiver leurs assertions d'image.
Artefacts et logs locaux ignorés : `build/shader254/`,
`build/shader254-check-build-pack-ambiance.log`,
`mods/sanctuary/build/libs/sanctuary-beta.254.jar`,
`build/Sanctuary-beta.254.mrpack`. Les sources et le manifeste portent
beta.254. Publication, canal stable et installation personnelle ne sont pas
modifiés dans ce ticket.
Le MRpack final contient un seul JAR Sanctuary, la bonne version Fabric,
Minecraft 26.3 et Fabric Loader 0.19.5. Intégrité ZIP et identité du JAR
embarqué vérifiées : **16 481 181 octets**, SHA-256
`1b070a3a5dd86f006167fc37276504aea3de9de47c6b4b76962ddd4677df3327`.
Copie identique conservée dans `%LOCALAPPDATA%\SanctuaryDev` sur Windows.
Les tâches Windows temporaires de validation sont supprimées après collecte.
+73
View File
@@ -0,0 +1,73 @@
# AUDIO-252 — Sound Physics Remastered en option
Branche `codex/sound-physics-option`, Minecraft **26.3**, livraison **beta.252**.
Le socle reprend beta.251 en entier, y compris les habitats des insectes et la
protection des maisons. Les sources de cette livraison, encore non commises dans
son worktree, sont conservées dans le commit de base `8e51250` sans modifier
ce worktree.
## Résultat
**Options → Sound Physics Remastered → Acoustique réaliste** active la
réverbération, l'absorption et la propagation de Sound Physics Remastered.
Le moteur est désactivé pour une configuration neuve ; une configuration
existante garde sa valeur d'activation et ses autres préférences.
Le JAR Fabric officiel **1.5.1+26.3** est inclus dans le JAR Sanctuary.
Aucune installation séparée de Sound Physics Remastered, Cloth Config ou
Mod Menu n'est nécessaire. La version amont est une bêta destinée explicitement
à Minecraft 26.3. Gradle et l'assemblage du pack vérifient son SHA-512, sa présence
unique, ses métadonnées et ses notices. Les sources et crédits sont consignés
dans `mods/sound-physics-remastered/PROVENANCE.md`, inclus avec la GPL version 3.
L'interface native FR/EN donne accès aux paramètres d'acoustique, de qualité,
de performances, de diagnostic, ainsi qu'à la réflexion et à l'occlusion par
matériau. Les réglages vocaux sont également accessibles ; leurs effets
nécessitent Simple Voice Chat, qui n'est pas ajouté. Les limites numériques
reprennent celles de l'amont, avec refus des valeurs hors plage et non finies.
La virgule décimale est acceptée ; Annuler ou Échap ne sauvegarde rien.
Les changements sont conservés dans les fichiers natifs de
`config/sound_physics_remastered/`. Le basculement relance le moteur audio pour
retirer les filtres des sons déjà actifs : les sons en cours sont brièvement
interrompus. Les autres réglages appliquent les paramètres sans redémarrer
le jeu. Les réglages de portée dépendent aussi de l'activation côté serveur
ou du solo, conformément au fonctionnement amont.
Aucune ressource AmbientSounds ni nouvel enregistrement d'ambiance n'est
ajouté. La génération, les formats de sauvegarde et les mondes restent ceux
du socle beta.251. Le pack est préparé localement ; aucune installation
personnelle ni canal public n'est modifié.
## Vérifications
Vérifications réussies le 8 octobre 2026, Java 25, Fabric Loader 0.19.5 et
Fabric API 0.160.5+26.3 :
- `./gradlew check build assemblePack assembleTestPack
-PsanctuaryFocusedTests=insects245 -PsanctuaryAtlasOnly=true` : 138 tâches,
21 GameTests requis, contrôles de ressources et assemblages réussis. Le serveur
dédié charge aussi le composant embarqué sans erreur.
- `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuarySound252ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan` :
menus FR/EN aux échelles 2 et 4, six catégories accessibles, configuration
neuve désactivée, ancien fichier activé conservé, basculement immédiat,
sauvegarde relue depuis le disque, validation des bornes et valeurs non
finies, virgule décimale et annulation réussis. OpenAL EFX initialise ses
quatre effets ; capture du menu dans le dossier de tests ignoré.
- Les sources, ressources et tests d'écologie de beta.251 sont comparés
octet par octet au worktree d'origine : identiques. Les deux MRpacks ont
leur version, leurs dépendances et leur intégrité ZIP vérifiées ; ils
embarquent le JAR Sanctuary final, sans JAR SPR séparé ni configuration
audio imposée aux préférences utilisateur.
Logs et reçu ignorés : `build/sound252-client.log`,
`build/sound252-check-build-pack.log`, `build/sound252-validation.json`.
L'écoute subjective de la réverbération et le coût sur d'autres machines
restent à évaluer en partie ; la suite historique complète des GameTests
n'est pas revendiquée.
Artefacts locaux : `build/Sanctuary-beta.252.mrpack` (13 537 390 octets,
SHA-256 `0e8ccd8b64c645503167ecb44dbd9cc5227aa182b7aeecec684d08883403ed2a`)
et `build/Sanctuary-Test-beta.252.mrpack` (13 951 183 octets,
SHA-256 `e1452203375599524383a018246f98283d08183520a911a0c4a231bb33a6e53f`).
+177
View File
@@ -0,0 +1,177 @@
# WAY-239 — Waystones et visites entre maisons
Branche `codex/waystones-beta239`, Minecraft **26.3**, livraison **beta.239**.
Le tarif de cette livraison est remplacé par [1 XP / 16 blocs en beta.240](waystones-tarif-beta240.md).
Les aptitudes permanentes et Forgerie beta.238 sont réunies avec le retour
au menu Pause beta.235 et le raccourci M conservé après l'essai annulé.
## Contrat jouable
Le même bloc `sanctuary:waystone` sert aux voyages entre maisons et aux
étages. Sa texture, son modèle de cube, ses particules et sa recette viennent
de `../26.2/iliketomoveit`, consulté comme référence sans modification.
La texture verte 16 × 16 est identique octet pour octet à l'original :
SHA-256 `f28cb78d0c8b722fdafc7fd313850d2884806b8861072e7842c0a106013d6e3a`.
Recette : huit pierres autour d'une perle de l'Ender. Le bloc se récupère
avec une pioche et figure dans l'onglet créatif des blocs fonctionnels.
- **Découverte personnelle** : utiliser une pierre ou se tenir dessus la
découvre. Les destinations horizontales doivent avoir été visitées.
- **Étages** : même X et Z, Y différent, dans la même dimension. Les étages
supérieurs et inférieurs fonctionnent avant découverte ; l'arrivée les
découvre. Les pierres d'une colonne restent aussi utilisables entre maisons.
- **Raccourcis** : sur la pierre, Saut monte et S'accroupir descend vers
l'étage le plus proche. Les touches suivent les contrôles du joueur.
Maintenir la touche ne traverse pas plusieurs étages ; la relâcher puis
l'appuyer de nouveau permet le déplacement suivant. Délai de 10 ticks
entre deux téléportations réussies.
- **Nom** : clic droit, champ de 48 caractères, bouton Renommer ou Entrée.
Le nom est partagé entre les habitants ; le visiteur doit être à proximité
d'une pierre découverte. Un objet renommé à l'enclume transmet aussi son
nom lors de la pose. Il n'y a pas de permission de propriétaire ajoutée.
- **Carte** : texture originale comme marqueur, nom et coordonnées au survol,
uniquement pour les pierres découvertes. Une colonne a une seule icône
avec le nom de l'étage le plus proche en altitude ; son survol liste les
étages connus. Marqueurs dans l'Atlas et dans la carte de Pause.
**M**, configurable dans les contrôles, ouvre la carte depuis le jeu ou
Pause lorsque l'Atlas est acquis ; fermer l'Atlas retrouve le même menu
Pause. Saisir M dans le champ de nom reste une saisie.
Le coût en survie est **un point d'XP pour huit blocs**, arrondi au supérieur
à chaque trajet :
```text
distance = √(ΔX² + ΔY² + ΔZ²)
coût = ceil(distance / 8)
```
Les dénivelés comptent aussi entre maisons. Un étage de 8 blocs coûte 1 point,
un déplacement de 9 blocs en coûte 2. Il s'agit de points, pas de niveaux.
Le solde est reconstruit à partir du niveau et de la barre actuels, sans
utiliser le compteur historique `totalExperience`. Le créatif est gratuit.
Le serveur contrôle source proche, découverte, destination existante,
XP et place d'arrivée sûre ; un refus ne débite rien. L'arrivée se fait
au-dessus de la pierre ou sur un sol libre à deux blocs autour au maximum.
Les voyages entre dimensions ne sont pas proposés.
## Simulation de vie de serveur sur Sanctuary Grand
Monde de développement **neuf**, graine **0**, preset actuel
`sanctuary:island231_large`, diamètre **1024**, génération révision **231**.
Le test contrôle effectivement ce générateur et cette taille. Huit petites
maisons en bois sont posées sur le relief natif, avec deux étages de plus
à la maison centrale. La hauteur réelle du relief intervient dans les coûts.
| Maison / étage | X | Y de la pierre | Z |
| --- | ---: | ---: | ---: |
| Centre | 24 | 257 | -24 |
| Alice | 48 | 250 | 24 |
| Bob | 112 | 251 | 24 |
| Clara | 80 | 240 | 88 |
| Nord | 0 | 215 | -176 |
| Est | 224 | 251 | -96 |
| Sud | 64 | 413 | 256 |
| Ouest | -176 | 252 | 64 |
| Centre 1 | 24 | 265 | -24 |
| Centre 2 | 24 | 273 | -24 |
Chaque joueur de la fixture est placé successivement auprès des huit
pierres pour la reconnaissance, hors budget. Les étages commencent inconnus. Chaque profil reçoit exactement **10 niveaux
et une barre vide, soit 160 points**, sans gain d'XP pendant les visites.
Les déplacements utilisent les fonctions de jeu natives du serveur et
vérifient chaque destination, débit et refus. Trois habitants simulés ont
un UUID, une découverte et un solde indépendants ; ce banc ne mesure pas
la charge réseau de plusieurs clients distants.
| Parcours répété depuis Centre | Trajets acceptés | Distance cumulée 3D | XP dépensée | XP restante | Trajet suivant refusé |
| --- | ---: | ---: | ---: | ---: | --- |
| Quartier : Alice → Bob → Clara → Centre | **15** | **1 141,20 blocs** | 152 | 8 | Clara → Centre : 16 XP |
| Île : Nord → Est → Centre → Sud → Ouest → Centre | **4** | **935,74 blocs** | 119 | 41 | Sud → Ouest : 44 XP |
| Mixte : Centre 1 → Centre 2 → Centre → Alice → Bob → Clara → Centre | **26** | **1 196,80 blocs** | 158 | 2 | Bob → Clara : 10 XP |
Le parcours mixte contient **14 voyages entre maisons et 12 déplacements
d'étage**. Les refus conservent position et XP. Les 41 points restant au Sud
permettraient d'autres destinations plus proches ; ils ne suffisent pas
pour l'étape suivante prévue vers Ouest. Un solde de 160 points représente
au plus 1 280 blocs si tous les trajets sont des multiples de huit ; les
arrondis par trajet diminuent cette portée. Il n'existe donc pas un nombre
universel de visites avec dix niveaux.
Traces détaillées, chaque trajet et chaque solde :
[JSON](../build/waystones239-validation/waystones239-large-seed-0.json),
[CSV](../build/waystones239-validation/waystones239-large-seed-0.csv).
Fixtures reproductibles : `WaystoneLife239` et `WaystoneLarge239GameTests`.
Les diagnostics et les mondes restent dans des répertoires de build ignorés.
## Données et compatibilité
Nouveaux fichiers SavedData version 1 : `sanctuary:waystones` pour l'index
partagé dimension/position/nom et `sanctuary:waystone_discoveries` pour les
visites par UUID. Limite de 1 024 pierres indexées dans le serveur et de
1 024 découvertes par joueur. Une pierre supprimée disparaît de l'index ;
les entrées invalides sont vérifiées dans les chunks déjà chargés. Le chunk
d'une destination connue est chargé uniquement après contrôle du droit et
du budget. Aucun balayage ni préchargement de l'île n'est ajouté.
Paquets bornés `sanctuary:waystone_travel_v1`, `sanctuary:waystone_rename_v1`
et `sanctuary:waystone_open_v1`. Le client transmet la source, la cible ou
le nouveau nom ; le serveur calcule le prix. FR et EN ajoutés. Aucun mod
externe ni dépendance supplémentaire ; APIs vérifiées et compilées avec
Minecraft 26.3, Fabric API 0.160.5+26.3 et Fabric Loader 0.19.5 épinglés.
L'index est additif. Les anciens blocs et données `iliketomoveit:*` ne sont
pas convertis. Aucun format existant, terrain, expansion ou monde personnel
n'est modifié. Un éventuel import historique nécessitera un contrat distinct.
## Vérifications
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=waystones239,menus,aptitudes236,forgerie238 \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runGameTest \
-PsanctuaryFocusedTests=waystonesLarge239 \
-PsanctuaryWaystonesLarge239Tests=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryWaystones239ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan
```
- `check build assemblePack` réussi en **2 min 31 s** : contrôles purs du dépôt
et **21 GameTests
requis**. Six nouveaux tests couvrent prix 3D, budget natif, découvertes
personnelles, étages inconnus dans les deux sens, touches sans cascade,
source distante, cible cassée, arrivée obstruée, manque d'XP, renommage,
aller-retour des codecs et marqueurs. Menus, aptitudes et Forgerie
réunis passent aussi. Le banc plat ciblé évite le chemin de génération
par défaut déjà documenté en beta.232.
- **Deux GameTests sur le véritable Grand** : preset/générateur actuel
vérifié et simulation complète des trois parcours ; réussis en 1 min 21 s.
- **Client natif Vulkan / MoltenVK 1.4.2, Apple M1**, réussi en **1 min 34 s**,
véritable Grand graine 0 :
Saut maintenu, second appui, S'accroupir maintenu, un étage par appui et
débit exact ; menus FR/EN GUI 2/3/4, noms via bouton et Entrée, saisie de M,
visites aller-retour entre Centre et Alice par les boutons réels,
M depuis le jeu et Pause, nom partagé et dix marqueurs reçus dans chaque
carte, colonnes regroupées sans perdre les étages au survol.
Huit captures dans `build/waystones239-screenshots/`, carte M et Pause
inspectées : [carte M](../build/waystones239-screenshots/large-map-m.png),
[Pause](../build/waystones239-screenshots/large-pause-markers.png).
La carte montre les zones réellement relevées autour des
maisons de la fixture, sans dévoiler le reste de l'île.
Logs locaux : `build/waystones239-check-build-pack.log`,
`build/waystones239-large-seed0.log`, `build/waystones239-client-vulkan.log`.
Versions mod/pack/manifeste alignées sur **beta.239**.
[MRpack local](../build/Sanctuary-beta.239.mrpack) vérifié : 12 847 863 octets,
intégrité ZIP, Minecraft 26.3 / Fabric Loader 0.19.5, un seul JAR Sanctuary
identique au build final, classes et ressources Waystones présentes, texture
identique à 26.2, sans classe de GameTest ni monde. Reçu
`build/waystones239-mrpack-receipt.json`.
- SHA-256 JAR : `7f5903cf0bc34f48290a71b3cbdf57c3f0bb7dd12d317177d8abf795da8fc2a9`.
- SHA-256 MRpack : `12ea0e96b27fd91da50b839e81dfef1a62f844aa2038f1c5da694b7c93c4cf01`.
Aucun serveur distant ni Windows testé ; aucune publication ni
synchronisation personnelle.
+145
View File
@@ -0,0 +1,145 @@
# WAY-241 — Ascenseurs séparés, recherche et tarif administrable
Branche `codex/waystones-menu-beta241`, base beta.240 (`850ab17`),
Minecraft **26.3**, livraison **beta.241**.
## Contrat
Deux pierres ou plus alignées au même X/Z, dans une dimension, forment un
ascenseur. Il voyage uniquement dans sa colonne, dans les deux sens sans
découverte préalable des étages. Une pierre isolée est un téléporteur
horizontal : seules les autres pierres isolées découvertes sont proposées.
Le serveur refuse les voyages entre les deux réseaux, même via un paquet
fabriqué. Le classement utilise l’index complet, pas les seules découvertes.
Les textures et l’identifiant du bloc restent ceux de beta.239.
Retirer le dernier autre étage rend de nouveau la pierre isolée horizontale.
Les deux ascenseurs de colonnes distinctes ne communiquent pas entre eux.
Le menu a une recherche par nom, des boutons centrés portant les noms sans
altitude. La distance et l’XP apparaissent uniquement au survol du bouton,
selon la dernière demande ; le rappel permanent du tarif est retiré aussi.
Les noms automatiques en coordonnées sont présentés sans Y, sans réécrire
les noms sauvegardés. Un nom personnel est conservé tel quel.
Les mots `jump` et `sneak` désignent la montée et la descente, indépendamment
du nom physique des touches. `R` transforme la recherche en champ de
renommage ; Entrée valide et Échap annule. Dans un champ de saisie, R reste
une lettre normale ; Entrée quitte la recherche pour accéder au raccourci.
Recherche, saisie et défilement sont conservés au redimensionnement.
`/sanctuary waystone` consulte le tarif. `cost <xp> <blocs>` règle un ratio,
par exemple `cost 1 16`. `cost <xp_par_bloc>` accepte aussi un coût direct,
par exemple `cost 0.0625`. Le point décimal est requis pour les fractions.
Modification réservée au propriétaire solo, même sans commandes de triche,
aux opérateurs autorisés et à la console. Le défaut reste 1 point / 16 blocs,
avec un arrondi supérieur par trajet ; zéro rend les trajets gratuits.
Le tarif est sauvegardé dans un nouveau fichier dédié, sans modifier les
formats existants. Les menus ouverts se rafraîchissent lors d’un changement.
```text
/sanctuary waystone # consulter
/sanctuary waystone cost 1 16 # défaut : 1 point / 16 blocs
/sanctuary waystone cost 0.0625 # même ratio, exprimé par bloc
/sanctuary waystone cost 2 16 # 2 points / 16 blocs
coût = ceil(√(ΔX² + ΔY² + ΔZ²) × xp / blocs)
```
Le ratio est appliqué avant l’arrondi, donc doubler le tarif ne double pas
nécessairement un prix déjà arrondi. Centre–Alice mesure 54,120 blocs :
4 points au défaut, 7 points avec `cost 2 16`. Les refus continuent de
conserver le solde, et le prix est recalculé côté serveur au départ.
## Données et protocole
Nouveau SavedData `sanctuary:waystone_settings`, codec version 1, champs
`xp` et `blocks` ; défaut lorsque le fichier est absent. Sauvegarde native
avec le monde. XP fini entre 0 et 1 000 000 ; diviseur entier entre 1 et
30 000 000. Les coûts extrêmes sont plafonnés à l’entier maximal sans
débordement. Les formats d’index et de découverte version 1 sont conservés.
Nouveaux paquets `sanctuary:waystone_menu_v1` (réseau et tarif serveur),
`sanctuary:waystone_cost_changed_v1` (notification) et
`sanctuary:waystone_refresh_v1` (demande validée à proximité de la source).
Les identifiants et encodages Travel, Rename et Open précédents ne changent
pas ; le serveur courant envoie Menu. Client et serveur utilisent le même
pack beta.241. La recherche filtre localement les noms affichés, sans
modifier ni découvrir des destinations.
## Essais Grand
Aucun monde existant n’est modifié pour les essais. La fixture utilise un
nouveau Grand graine 0, preset `sanctuary:island231_large`, génération 231,
diamètre 1024. Elle garde les huit téléporteurs entre maisons et place
l’ascenseur à 8 blocs de Centre, en X/Z (24, −16), avec des étages espacés
de 8 blocs. Une liaison à pied est possible, mais sa durée n’est pas mesurée.
Les anciennes mesures mixtes beta.240 ne décrivent plus ce réseau séparé.
Les trois habitants simulés commencent chacun à 10 niveaux, barre vide,
soit 160 points, après reconnaissance des maisons et de la base de
l’ascenseur. Ils voyagent jusqu’au refus pour manque d’XP, sans gain d’XP
pendant les visites. Les étages ne sont pas découverts avant le premier
déplacement. Chaque arrivée, débit et refus est vérifié sur le serveur natif.
| Parcours répété | Trajets | Distance 3D cumulée | XP dépensée | XP restante |
| --- | ---: | ---: | ---: | ---: |
| Quartier : Alice → Bob → Clara → Centre | **29** | 2 272,36 blocs | 158 | 2 |
| Île : Nord → Est → Centre → Sud → Ouest → Centre | **10** | 2 437,00 blocs | 158 | 2 |
| Ascenseur : Centre 1 → Centre 2 → Ascenseur | **160** | 1 704 blocs | 160 | 0 |
Les deux tests natifs Grand passent en **2 min 23 s**. Les trajets
horizontaux gardent les mesures beta.240 ; le parcours d’étages est
maintenant indépendant. Ce banc simule les habitants dans le serveur natif,
sans clients distants ni mesure de charge réseau. Traces :
[JSON](../build/waystones241-validation/waystones241-large-seed-0.json),
[CSV](../build/waystones241-validation/waystones241-large-seed-0.csv),
log `build/waystones241-large-seed0.log`.
## Vérifications et artefact
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=waystones239 -PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runGameTest \
-PsanctuaryFocusedTests=waystonesLarge239 \
-PsanctuaryWaystonesLarge239Tests=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryWaystones239ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan
```
Client **Vulkan / MoltenVK 1.4.2, Apple M1**, réussi en **1 min 45 s** :
FR/EN GUI 2/3/4, réseaux distincts, boutons centrés, noms automatiques sans Y,
recherche et curseur conservés au redimensionnement, R/M littéraux pendant
la saisie, R pour renommer, Entrée pour confirmer et Échap pour annuler.
Trois appuis jump/sneak accèdent aux étages inconnus avec un point par appui,
sans cascade pendant le maintien. Le propriétaire solo règle `cost 2 16`
sans activer les commandes de triche ; le menu ouvert reçoit le nouveau prix
et l’aller-retour natif Centre–Alice coûte effectivement **14 points**.
Le tarif est ensuite rétabli au défaut. M ouvre la carte depuis le jeu et
Pause ; les deux cartes reçoivent les 11 pierres découvertes, regroupées en
9 icônes dans l’Atlas. Log `build/waystones241-client-vulkan.log`, marqueur
`WAYSTONES241_CLIENT_PASS`.
Captures inspectées : [liste FR GUI 3](../build/waystones241-screenshots/horizontal-fr_fr-3.png),
[ascenseur EN GUI 4](../build/waystones241-screenshots/elevator-en_us-4.png),
[prix personnalisé au survol](../build/waystones241-screenshots/configured-rate-hover.png).
Distance et prix sont absents des lignes et présents dans l’infobulle.
`check build assemblePack` réussi en **2 min 38 s**, avec les contrôles purs
du dépôt et **9 GameTests requis** : les six scénarios Waystones précédents,
la séparation des réseaux avec arrivée/départ forgés, les commandes et le
codec du tarif, plus le test du framework. Les essais plats ciblent le
comportement ; l’essai Grand séparé vérifie le générateur distribué actuel.
Log `build/waystones241-check-build-pack.log`.
Mod, pack et manifeste alignés sur **beta.241**.
[MRpack local](../build/Sanctuary-beta.241.mrpack) vérifié : **12 862 282 octets**,
ZIP intègre, Minecraft 26.3 / Fabric Loader 0.19.5, un seul JAR Sanctuary
identique au build final, FR/EN alignés, texture originale 26.2 identique
octet pour octet, sans GameTests ni mondes embarqués. Reçu
`build/waystones241-mrpack-receipt.json`.
- SHA-256 JAR : `88ff827dc8e036a4f4a30b83045598560a75a1cc4934816e6fdb4c0106ef469f`.
- SHA-256 MRpack : `ac8cda7c52390534526d5f02bda54e1ef5c2939afe811b14daac47f7ddfe6e5b`.
Livraison locale, sans publication ni mise à jour d’installation personnelle.
Génération, chunks et anciens formats de sauvegarde conservés ; aucune
expansion n’est activée par ce changement.
+103
View File
@@ -0,0 +1,103 @@
# WAY-240 — Tarif des Waystones : un point pour seize blocs
Branche `codex/waystones-tarif-beta240`, base beta.239 (`68ce6de`),
Minecraft 26.3, livraison **beta.240**.
À la demande du créateur, le coût des trajets devient **1 point d’XP pour
16 blocs**, horizontalement et verticalement :
```text
coût = ceil(√(ΔX² + ΔY² + ΔZ²) / 16)
```
L’arrondi supérieur par trajet reste en vigueur. De 1 à 16 blocs : 1 point ;
17 blocs : 2 points ; 32 blocs : 2 points ; 512 blocs : 32 points. Les deux
étages de 8 blocs de la fixture coûtent toujours 1 point par déplacement.
Les 10 niveaux de départ, avec une barre vide, représentent **160 points**.
La portée théorique maximale passe de 1 280 à **2 560 blocs**, avant les
arrondis par trajet.
Le prix est calculé par le serveur. Le rappel FR/EN du menu reçoit désormais
la valeur du tarif depuis la même constante, pour suivre les changements
suivants. Les règles de découverte, de renommage, d’arrivée sûre, les
raccourcis Saut / S’accroupir, la carte M et les textures originales gardent
le [contrat beta.239](waystones-beta239.md). Aucun format de sauvegarde,
identifiant de bloc ou paquet ne change.
## Comparaison sur Sanctuary Grand
Le banc existant est relancé dans un **nouveau monde**, graine **0**, preset
`sanctuary:island231_large`, diamètre 1024, génération révision 231.
Les huit maisons et deux étages ont les mêmes coordonnées et hauteurs
que beta.239. Chaque profil dispose de 160 points après reconnaissance,
sans aucun gain d’XP pendant les visites. Les débits et les arrivées sont
vérifiés après chaque téléportation ; les refus doivent conserver le solde
et la position. Il s’agit de trois habitants simulés sur le serveur natif,
sans mesure de charge réseau de clients distants.
| Parcours répété depuis Centre | Trajets à 8 blocs/XP (beta.239) | Trajets à 16 blocs/XP | Distance cumulée 3D | XP dépensée | XP restante | Refus suivant |
| --- | ---: | ---: | ---: | ---: | ---: | --- |
| Quartier : Alice → Bob → Clara → Centre | 15 | **29** | **2 272,36 blocs** | 158 | 2 | Alice → Bob : 5 XP |
| Île : Nord → Est → Centre → Sud → Ouest → Centre | 4 | **10** | **2 437,00 blocs** | 158 | 2 | Sud → Ouest : 22 XP |
| Mixte : Centre 1 → Centre 2 → Centre → Alice → Bob → Clara → Centre | 26 | **46** | **2 179,47 blocs** | 157 | 3 | Alice → Bob : 5 XP |
Le parcours mixte comprend **25 voyages entre maisons et 21 déplacements
d’étage**. Le nombre de voyages n’est pas nécessairement doublé : les
arrondis se font à chaque trajet, les déplacements de 8 blocs restent à
un point, et les parcours s’arrêtent lorsque l’étape suivante dépasse le
solde restant. La reconnaissance et les trajets ont été relancés dans le
serveur de jeu, avec vérification du générateur actuel ; les mesures ne
proviennent pas d’une simple division des anciennes valeurs.
Traces : [JSON](../build/waystones240-validation/waystones240-large-seed-0.json),
[CSV](../build/waystones240-validation/waystones240-large-seed-0.csv).
Log `build/waystones240-large-seed0.log` ; les deux tests requis sur Grand
réussissent en **1 min 45 s**.
## Vérifications et artefact
```sh
./gradlew :sanctuary:runGameTest \
-PsanctuaryFocusedTests=waystonesLarge239 \
-PsanctuaryWaystonesLarge239Tests=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryWaystones239ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=waystones239 -PsanctuaryAtlasOnly=true
```
Les tests existants sont adaptés aux bornes 16/17, aux étages de 16 blocs,
au refus avec un point pour un trajet de 24 blocs, et à l’aller-retour natif
Centre–Alice : **4 points par sens, 8 au total**, contre 14 auparavant.
Les diagnostics incluent la version courante et `blocks_per_xp`, et les
captures sont enregistrées par version pour préserver celles de beta.239.
Parcours client **Vulkan / MoltenVK 1.4.2 sur Apple M1**, réussi en
**1 min 39 s** : prix et rappel du tarif contrôlés en FR/EN GUI 2/3/4,
montée/descente natives à un point par appui sans cascade, renommage,
aller-retour Centre–Alice via les boutons natifs avec un débit total de
8 points, M depuis le jeu et Pause, marqueurs dans les deux cartes.
Les captures [FR GUI 3](../build/waystones240-screenshots/menu-fr_fr-3.png) et
[EN GUI 4](../build/waystones240-screenshots/menu-en_us-4.png) sont inspectées :
le tarif et les prix tiennent dans le cadre. Log
`build/waystones240-client-vulkan.log`, marqueur `WAYSTONES239_CLIENT_PASS`
du banc réutilisé.
`check build assemblePack` réussi en **2 min 46 s** : contrôles purs du dépôt
et **7 GameTests requis**, dont les six scénarios Waystones avec le nouveau
tarif. Le banc ciblé plat suit le workflow beta.239 ; la génération actuelle
est vérifiée dans l’essai Grand séparé. Log
`build/waystones240-check-build-pack.log`.
Versions du mod, du pack et du manifeste alignées sur **beta.240**.
[MRpack local](../build/Sanctuary-beta.240.mrpack) vérifié :
12 847 859 octets, intégrité ZIP, Minecraft 26.3 / Fabric Loader
0.19.5, un seul JAR Sanctuary identique au build final, texture originale
26.2 conservée, sans GameTest ni monde. Reçu
`build/waystones240-mrpack-receipt.json`.
- SHA-256 JAR : `cfb178f4799bcc71a2692f736ddf4c4b1bc6420be542490c686ae1fcc7273903`.
- SHA-256 MRpack : `37979aed768e2b60c303ab4b272fbe33dac8f511aaf0284a6dd183aef8e6d1a5`.
La livraison reste locale, sans publication ni installation personnelle.
+111
View File
@@ -0,0 +1,111 @@
# SHADER-258 — Continuité des ombres autour du zénith
Branche `codex/zenith-shadows-beta258`, socle `beta.257`.
## Demande et contrat
Après une session en profil Élevé, VSync et environ 60 FPS stables, le créateur
signale des ombres coupées et saccadées vers midi–13 h. Reproduire et corriger
le calcul des ombres autour du zénith, sans augmenter les budgets du profil.
Conserver les profils, les rayons à 20 %, le SSR en Moyen, les mods facultatifs
et les réglages personnels. Aucune modification de génération, sauvegarde ou
horloge serveur ; scènes de validation neuves et Vulkan uniquement.
## Demande complémentaire : musique
Le créateur demande un démarrage quelques secondes après la fin du chargement
Mojang et une lecture réservée aux menus : pas dans Hello World, ni pendant
l'apparition du monde. Délai retenu : trois secondes après disparition de
l'overlay, réarmé après chargement/reconnexion. La musique propre à l'introduction
reste indépendante du morceau de menu. Vérifier le véritable canal audio natif,
la navigation Options et le rechargement des ressources.
## Cause reproduite et correction
La scène neuve utilise une falaise d'environ 80 blocs, des terrasses, des
renfoncements et des surplombs, avec plusieurs sections verticales. Le profil
Élevé conserve sa carte 2048² sur 128 blocs. Les autres effets sont isolés
pour comparer les ombres ON/OFF à 1080p sur Vulkan, avec Sodium et `-Xmx8G`.
Le test opaque comparait la profondeur au point demandé, alors que le sampler
NEAREST lit le centre d'un texel voisin. Sur une paroi presque parallèle au
soleil, ce petit écart devient une grande erreur en hauteur : fausses bandes
de plus en plus larges avant midi. Chaque comparaison utilise maintenant le
centre réellement lu, sur le même plan récepteur.
Près du zénith, l'extrapolation de ce plan est bornée à un bloc dans l'axe de
profondeur, les texels restés à la profondeur de clear sont explicitement vides,
et le seuil des cofacteurs ne supprime plus une pente valide à grande portée.
Le biais normal couvre au moins un demi-texel aux incidences rasantes, afin
qu'une falaise ne lise pas son propre sommet. Aucun fondu d'intensité ni arrêt
des ombres des falaises. Portées, résolution, filtre et nombre de lectures de
texture conservent les budgets existants ; quelques opérations arithmétiques
sont ajoutées. Aucun nouveau gain de FPS n'est annoncé.
La première correction seule retire les bandes sur la zone plane comparée :
28 transitions parasites à 11 h 30, 14 à 11 h 45 et 7 à 11 h 54 deviennent zéro.
Les essais supplémentaires vérifient que le cas limite du zénith conserve les
vraies ombres sous les surplombs.
La correction complète conserve zéro transition parasite dans cette zone,
y compris à 5999/6000/6001 ticks. Sur les 1 640 points projetés indépendamment
sur les parois, aucun point exposé au soleil n'est faussement ombré avant midi ;
les 1 640 points sous les surplombs restent ombrés dans les contrôles proches
du zénith. Après le passage du soleil derrière la falaise, la paroi passe bien
dans l'ombre. Les images sont également inspectées, pas seulement les compteurs.
## Vérifications et distribution
`./gradlew check build assemblePack assembleTestPack` réussit avec
`-PsanctuaryFocusedTests=census,menus -PsanctuaryAtlasOnly=true` :
six GameTests serveur requis, contrôles purs et deux assemblages réussis.
Comme sur le socle beta.257, le harnais historique `unified` sans le fixture
plat conserve un cast de générateur incompatible ; ces paramètres ciblent
les contrôles utiles sans modifier la génération. Les contrôles client
compilent séparément avec Java 25.
Client natif Windows, Minecraft 26.3, Vulkan, RX 9070 XT, Sodium présent,
mémoire Java plafonnée à 8 Gio. La scène de relief utilise une fenêtre
1920 × 1080, le profil Élevé et six chunks chargés ; le contrôle historique
sol/feuilles utilise sa fenêtre 854 × 480. Ce n'est pas un benchmark du monde
Grand à 32 chunks :
- Falaise neuve, graine `122`, quinze positions entre 4000 et 8000 ticks,
dont les pas 5999/6000/6001, comparaisons ON/OFF, stabilité à soleil figé,
conservation des ombres des surplombs et trois passages d'ancre caméra.
- Horloge civile Sanctuary : 10 h, 11 h, 12 h, 13 h et 14 h, puis midi solaire
±30 secondes. L'horloge injectée et sa synchronisation native appartiennent
uniquement au test ; l'horloge serveur de production ne change pas.
- Ombres au sol, silhouettes des feuilles et stabilité des ancres : contrôle
ciblé existant `sanctuaryAnchor256ClientTests`.
- Véritable flux audio OGG stéréo : silence initial, démarrage différé,
continuité dans Options, titres FR/EN, rechargement des ressources, reprise
après fin du morceau et arrêt dans Hello World et l'introduction.
Le contrôle des ancres sur la canopée a également révélé un aléa du harnais :
des particules de feuilles tombantes bougeaient entre deux captures de caméra
fixe, alors que le motif d'ombre ne bougeait pas. Les scènes de mesure utilisent
maintenant le réglage natif de particules minimal, restauré après le test.
La tolérance de différence d'image reste inchangée à 1 % ; aucun réglage de
particules du jeu normal n'est modifié.
Les trois suites natives ciblées réussissent : relief et horloge civile
(5 min 44), sol/feuilles/ancres (1 min 24) et musique (41 s).
Les 4 163 fichiers de sources contrôlés sur Windows correspondent au miroir
local. Les deux archives sont intègres, à la version beta.258, et contiennent
exactement le JAR construit ; le shader embarqué correspond aux sources finales.
JEI reste un JAR séparé, les trois mods client restent facultatifs.
[Reçu détaillé](../build/zenith258/validation.json),
[captures des falaises](../build/zenith258/verified/) et
[comparaison autour du zénith](../build/zenith258/cliff-comparison.png).
| Artefact local | SHA-256 |
| --- | --- |
| `sanctuary-beta.258.jar` | `cf24d699b8c58a45dae1057c247ec83bddc88fdd20484bbb286931499695c918` |
| [Pack normal](../build/Sanctuary-beta.258.mrpack) | `5a3b4960992e9e5446d3f0cb65a653a6030da7f25fa52b4d1d8e4e6f27ce0329` |
| [Pack Test](../build/Sanctuary-Test-beta.258.mrpack) | `2ce78dfd26c1f5cb3e4e627613d840fab6974daf5eeeb4a26904f54d94a8ebb5` |
Aucun nouveau benchmark FPS ni essai plein écran ;
aucune validation OpenGL. La suite graphique générale SSGI/SSR/PBR n'est pas
revendiquée pour ce correctif ciblé. Aucun canal public, instance Prism,
serveur personnel ou monde existant n'est modifié.
+133
View File
@@ -0,0 +1,133 @@
# ZIP-242 — Tyroliennes de 26.2 et grappin portatif
Branche `codex/ziplines-grappling-beta242`, base beta.241, Minecraft **26.3**,
livraison locale **beta.242**. Ticket issu de la demande du 7 octobre 2026.
## Contrat jouable
Les tyroliennes fixes reprennent les poulies orientables de Sanctuary 26.2,
leurs modèles, leurs recettes et leurs câbles visibles. Une laisse sélectionne
la première poulie, puis relie la seconde : une laisse consommée par liaison,
distance maximale **512 blocs**, même dimension, une liaison par poulie.
Clic droit pour voyager dans les deux sens ; s’accroupir pour lâcher.
Accroupissement + clic droit avec une laisse retire la liaison sans
consommer de laisse supplémentaire. Le geste est vérifié avec le vrai client.
La vitesse historique est conservée : **0,58 bloc/tick**. Les poulies émettent
un signal de redstone 15 pendant un trajet, puis zéro lorsque celui-ci finit.
Le signal transitoire est corrigé après rechargement, car un trajet n’est pas
sauvegardé. Une liaison dont l’autre chunk est déchargé reste enregistrée ;
le trajet est refusé jusqu’à son chargement normal : retenter le clic
droit ensuite. Aucun chargement de terrain n’est forcé.
Le **Grappling Rod** conserve le lancer chargé de 26.2 : maintenir puis
relâcher le clic droit, charge maximale en 24 ticks, vitesse initiale
1,55 à 4,45 blocs/tick, inertie du joueur ajoutée et plafonnée à 5,6.
Gravité du projectile 0,035 et traînée 0,985 ; portée de vol 28 à 120 blocs
selon la charge. En survie, chaque lancer réussi consomme **une laisse**
de l’inventaire ou de la seconde main et un point de durabilité.
La laisse n’est jamais restituée. Sans laisse, le lancer ne coûte rien
et le joueur reçoit une explication FR/EN. Le créatif dispense de la laisse.
Lorsque le crochet touche un bloc solide, le serveur fixe le départ du
câble à la position actuelle du joueur et lance automatiquement le trajet
vers le crochet, à la vitesse de la tyrolienne fixe. Le câble est temporaire,
sans bloc posé ni corde persistante. **Aucun pendule ni balancement**.
Le joueur est suspendu sous la ligne ; l’arrivée s’arrête à 0,95 bloc du
crochet, puis la gravité normale reprend. Prévoir une plateforme sous
l’arrivée pour ne pas tomber après un trajet dégagé.
Le volume complet du joueur est vérifié tous les 0,20 bloc sur chaque
déplacement. Un obstacle sur ce volume coupe immédiatement le trajet et
déclenche une chute, même si le câble passe au-dessus du bloc. Aucun passage
à travers un mur, aucune protection permanente contre les dégâts de chute.
Le second clic droit, l’accroupissement, le rangement du grappin, la mort,
la déconnexion, un changement de dimension, la perte de l’ancrage ou
l’expiration du projectile suppriment le crochet et son câble.
Un joueur ne cumule pas un trajet fixe et un trajet portatif.
## Recettes et ressources
Les recettes façonnées de 26.2 sont conservées, avec les identifiants Beta :
| Objet | Recette historique |
| --- | --- |
| `sanctuary:grappling_hand` | Quatre lingots de fer autour d’une ficelle |
| `sanctuary:grappling_rod` | Trois bâtons en diagonale, une ficelle et un crochet |
| `sanctuary:pulley_head` | Quatre blocs de fer autour d’un crochet |
Modèles de poulie, crochet, canne tenue et canne lancée importés ; butin
du bloc et onglet créatif Outils intégrés. Les câbles utilisent le rendu
natif des laisses tendues, segmentées et éclairées sur toute leur longueur.
Les deux états du modèle du grappin suivent la présence réelle du crochet.
**La texture sera faite par le créateur**, conformément à sa dernière
demande. Les trois PNG de 26.2 restent provisoirement identiques aux originaux :
- `mods/sanctuary/src/main/resources/assets/sanctuary/textures/item/grappling_rod.png`
pour l’objet tenu ;
- `mods/sanctuary/src/main/resources/assets/sanctuary/textures/item/grappling_rod_cast.png`
pour l’objet après le lancer ;
- `mods/sanctuary/src/main/resources/assets/sanctuary/textures/item/grappling_hook.png`
pour le projectile et l’ingrédient crochet.
La proposition générée dans le chat n’est pas intégrée aux sources ni aux
artefacts. Aucun remplacement graphique final n’est revendiqué.
## Sauvegardes et limites
Nouveaux identifiants `sanctuary:pulley_head`, `sanctuary:grappling_hand`,
`sanctuary:grappling_rod` et `sanctuary:grappling_hook`. Les anciennes
sauvegardes `iliketomoveit:*` et `spiderman:*` de 26.2 ne sont pas migrées.
Les liens des nouvelles poulies sont enregistrés dans leurs propres entités
de bloc (`ConnectedPos`). Les trajets et crochets sont transitoires et
non sauvegardés. Aucun format existant, générateur ou chunk n’est réécrit.
L’ancienne main de grappin est l’ingrédient et le modèle du crochet, sans
porter séparément l’ancien système de balancement. Aucun nouveau mod externe
n’est ajouté : l’intégration utilise Minecraft 26.3 et Fabric API du pack.
La vitesse et la portée restent à apprécier en partie ; l’essai multijoueur
avec clients distants et latence réelle n’est pas couvert.
## Vérification
**Dix GameTests requis passent** sur un monde de développement neuf :
neuf scénarios ciblés et le test du framework. Ils vérifient les trois recettes
natives, la sauvegarde des liaisons, les trajets fixes dans les deux sens,
la redstone, la casse d’une poulie, les collisions fixes et portatives,
la récolte avec une pioche adaptée, la conservation d’une liaison
déchargée sans chargement forcé, la consommation exacte des laisses,
le lancer chargé, l’arrivée sans
balancement, l’expiration, la perte de l’ancrage et l’annulation.
```sh
./gradlew :sanctuary:runGameTest \
-PsanctuaryFocusedTests=ziplines242 -PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryZiplines242ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=ziplines242 -PsanctuaryAtlasOnly=true
```
Le test client **Vulkan / MoltenVK 1.4.2 / Apple M1** réussit en **1 min 14 s** :
les 12 états de poulie ont leur géométrie et leurs textures ; les câbles
fixes et temporaires utilisent des laisses tendues segmentées. Les libellés
FR/EN, le lancer par maintien/relâchement natif, le modèle lancé, le coût
exact d’une laisse, l’annulation par accroupissement, la chute effective
après collision et la déconnexion des deux poulies par le geste natif
accroupissement + laisse sont vérifiés.
Log `build/ziplines242-client-vulkan.log`, marqueur `ZIPLINES242_CLIENT_PASS`.
Captures inspectées : [câble portatif](../build/ziplines242-screenshots/portable-cable.png),
[chute après collision](../build/ziplines242-screenshots/collision-fall.png).
**`check build assemblePack` réussit en 2 min 38 s** sur les sources finales,
avec les contrôles purs du dépôt et les dix GameTests requis.
Log `build/ziplines242-check-build-pack.log`.
[MRpack local beta.242](../build/Sanctuary-beta.242.mrpack) vérifié :
ZIP intègre, Minecraft 26.3 / Fabric Loader 0.19.5, un seul JAR Sanctuary,
identique au JAR construit ; recettes et textures provisoires dans le JAR.
Mod, pack et manifeste alignés sur beta.242.
Reçu avec taille et empreintes SHA-256 : `build/ziplines242-validation.json`.
Aucune publication, modification du canal
packwiz ni mise à jour d’une installation personnelle n’est demandée.
+6 -3
View File
@@ -9,8 +9,11 @@ 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.235
pack_version=beta.235
mod_version=beta.264
pack_version=beta.264
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
jei_version=30.32.0-sanctuary.4
sound_physics_version=1.5.1+26.3
sound_physics_artifact=9loJgQlO
sound_physics_sha512=c6bbe6e2018a19fa98e9590f17feeac17ccea4eb2327f23915a4c112f4a7ed99220f99b9d2a9ee3c9a9919a847a8965d9d82aaee67371d4d8d675b3fcf4b46a5
+7 -2
View File
@@ -2,7 +2,7 @@
Source officielle : https://github.com/mezz/JustEnoughItems/tree/26.2
Commit : `aae2dfcfb82e6b5bce787ba72bb2eda7339b274f`.
Version source 30.32.0 ; fork `30.32.0-sanctuary.3` pour **Minecraft 26.3**
Version source 30.32.0 ; fork `30.32.0-sanctuary.4` pour **Minecraft 26.3**
(finale), Loader 0.19.5, API 0.160.5+26.3, Java 25.
Licence MIT, copyright 2014–2015 mezz. `LICENSE.txt` est inclus sans modification.
@@ -27,7 +27,9 @@ le cache ignoré manque, extrait le commit fixé et applique `patches/26.3.patch
La première reconstruction nécessite Internet, Git, patch et Python 3.
`./gradlew :jei:sourcesJar` produit aussi les sources Java effectivement compilées.
Le mod JEI est imbriqué dans le JAR Sanctuary avec deux dépendances :
Depuis beta.257, JEI est un JAR client séparé et facultatif. Sanctuary conserve
son pont de catalogue ; le retrait de JEI ne supprime aucune connaissance serveur.
JEI conserve ses deux bibliothèques internes nécessaires à la recherche :
- `net.mezzdev:baked-substring-index:0.1.0`, MIT, James Mitchell.
https://github.com/mezz/baked-substring-index — licence dans `licenses/baked-substring-index.txt`.
@@ -47,3 +49,6 @@ JEI, formats de réglages et mécanismes de recettes restent conservés.
La révision Sanctuary.3 cible Minecraft 26.3 finale et Fabric API 0.160.5+26.3.
La version Minecraft et les dépendances sont désormais reprises des propriétés
communes du projet ; le comportement du catalogue Sanctuary est conservé.
La révision Sanctuary.4 distribue ce portage comme mod séparé et facultatif.
Les fonctionnalités du catalogue et les deux bibliothèques de recherche sont conservées.
@@ -0,0 +1,284 @@
package fr.koka.sanctuarytest;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.testing.QuickTestWorld;
import java.nio.file.*;
import java.util.*;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.*;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.commands.Commands;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.*;
import net.minecraft.network.chat.*;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.*;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.*;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.monster.zombie.Zombie;
import net.minecraft.world.item.*;
import net.minecraft.world.level.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import net.minecraft.world.level.block.state.properties.AttachFace;
import net.minecraft.world.level.gamerules.GameRules;
import net.minecraft.world.level.storage.*;
import net.minecraft.world.phys.*;
/** Opt-in disposable combat map. No production world can invoke its builder or controls. */
public final class FamiliarLab {
public static final String WORLD="familiars-flat-259";
static final String PROP="sanctuary_familiar_lab259", ENEMY="sanctuary_familiar_lab259_enemy";
static final BlockPos DUMMY=new BlockPos(-12,81,4), SPAWN=new BlockPos(12,81,10);
static final BlockPos SPAWN_BUTTON=new BlockPos(7,82,-7), CLEAR_BUTTON=new BlockPos(11,82,-7), RESET_BUTTON=new BlockPos(-12,82,-7);
private static final AABB AREA=new AABB(-25,79,-17,26,100,26);
// Ordinary hostile combatants, excluding bosses and mobs requiring water or special structures.
static final List<EntityType<? extends Mob>> HOSTILES=List.of(EntityTypes.ZOMBIE,EntityTypes.SKELETON,
EntityTypes.HUSK,EntityTypes.STRAY,EntityTypes.BOGGED,EntityTypes.PARCHED,EntityTypes.SPIDER,
EntityTypes.CAVE_SPIDER,EntityTypes.CREEPER,EntityTypes.PILLAGER,EntityTypes.VINDICATOR,
EntityTypes.WITCH,EntityTypes.SILVERFISH,EntityTypes.ENDERMITE,EntityTypes.SLIME,
EntityTypes.MAGMA_CUBE,EntityTypes.BREEZE,EntityTypes.BLAZE,EntityTypes.WITHER_SKELETON,
EntityTypes.PIGLIN_BRUTE,EntityTypes.ZOGLIN,EntityTypes.RAVAGER,EntityTypes.EVOKER);
private static final Map<UUID,Mob> enemies=new HashMap<>();
private static final Map<UUID,Integer> lastButton=new HashMap<>();
private static MinecraftServer owner;
private static PunchingZombie dummy;
private static LabText meter;
private static long shownHits=-1;
private static long hits;
private static double total;
private static float last;
private static Component attacker=Component.empty();
private static int lastType=-1;
private FamiliarLab() {}
static void register() {
ServerLifecycleEvents.SERVER_STARTED.register(server->{if(Boolean.getBoolean("sanctuary.familiarLab"))prepare(server);});
ServerPlayConnectionEvents.JOIN.register((handler,sender,server)->{if(server==owner)server.execute(()->enter(handler.player));});
ServerPlayerEvents.AFTER_RESPAWN.register((old,p,alive)->{if(p.level().getServer()==owner)enter(p);});
ServerTickEvents.END_SERVER_TICK.register(server->{if(server==owner)tick();});
ServerLifecycleEvents.SERVER_STOPPED.register(server->{if(server==owner){owner=null;dummy=null;meter=null;enemies.clear();lastButton.clear();}});
PlayerBlockBreakEvents.BEFORE.register((level,player,pos,state,entity)->owner==null||level.getServer()!=owner||!AREA.contains(Vec3.atCenterOf(pos)));
UseBlockCallback.EVENT.register((player,level,hand,hit)->{
if(level.getServer()!=owner || owner==null || player.isSpectator())return InteractionResult.PASS;
if(player.getItemInHand(hand).getItem() instanceof SpawnEggItem&&!level.getBlockState(hit.getBlockPos()).is(Blocks.CHEST)){
if(player instanceof ServerPlayer p)p.sendOverlayMessage(text("equip"));return InteractionResult.FAIL;
}
if(player.getItemInHand(hand).getItem() instanceof BlockItem)return InteractionResult.FAIL;
if(hand!=InteractionHand.MAIN_HAND)return InteractionResult.PASS;
var pos=hit.getBlockPos();
if(!pos.equals(SPAWN_BUTTON)&&!pos.equals(CLEAR_BUTTON)&&!pos.equals(RESET_BUTTON))return InteractionResult.PASS;
if(!(player instanceof ServerPlayer p))return InteractionResult.PASS;
if(!p.isWithinBlockInteractionRange(pos,0)||!level.getBlockState(pos).is(Blocks.STONE_BUTTON))return InteractionResult.FAIL;
if(p.tickCount-lastButton.getOrDefault(p.getUUID(),-100)<4)return InteractionResult.SUCCESS;
lastButton.put(p.getUUID(),p.tickCount);
if(pos.equals(SPAWN_BUTTON))spawn(p);
else if(pos.equals(CLEAR_BUTTON)){clear();p.sendOverlayMessage(text("cleared"));}
else resetMeter();
return InteractionResult.PASS; // Keep the native button animation and sound.
});
CommandRegistrationCallback.EVENT.register((dispatcher,registries,environment)->{
var root=dispatcher.register(Commands.literal("familiers").requires(s->s.getServer()==owner)
.executes(c->{enter(c.getSource().getPlayerOrException());return 1;})
.then(Commands.literal("retour").executes(c->{enter(c.getSource().getPlayerOrException());return 1;}))
.then(Commands.literal("hostile").executes(c->spawn(c.getSource().getPlayerOrException())?1:0))
.then(Commands.literal("nettoyer").executes(c->{clear();return 1;}))
.then(Commands.literal("cible").executes(c->{var p=c.getSource().getPlayerOrException();p.teleportTo(owner.overworld(),-12.5,81,-1.5,Set.of(),0,10,false);return 1;}))
.then(Commands.literal("reset").executes(c->{resetMeter();return 1;})));
dispatcher.register(Commands.literal("familiars").redirect(root));
});
}
private static void prepare(MinecraftServer server) {
var world=server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
boolean isolated=false;
for(int i=0;i<world.getNameCount()-1;i++)if(world.getName(i).toString().equals("build")&&world.getName(i+1).toString().equals("familiars-259"))isolated=true;
if(!isolated||!world.getFileName().toString().equals(WORLD)||!QuickTestWorld.active(server))
throw new IllegalStateException("Familiar lab requires its isolated build/familiars-259 session and a fresh flat world");
var level=server.overworld();
for(int x=-2;x<=1;x++)for(int z=-1;z<=1;z++){level.getChunk(x,z);level.setChunkForced(x,z,true);}
var marker=world.resolve("familiars-lab-259.txt");
try {
if(!Files.exists(marker)) {
for(var pos:BlockPos.betweenClosed(new BlockPos(-24,80,-16),new BlockPos(24,90,24)))
if(!level.getBlockState(pos).isAir())throw new IllegalStateException("Existing scene preserved; choose a fresh run: "+pos);
Files.writeString(marker,"preparing\n",StandardOpenOption.CREATE_NEW);
build(level);Files.writeString(marker,"ready-v1\n");
} else if(!Files.readString(marker).equals("ready-v1\n")||!level.getBlockState(new BlockPos(0,80,-10)).is(Blocks.SEA_LANTERN))
throw new IllegalStateException("Incomplete familiar lab preserved; choose a fresh run");
}catch(java.io.IOException e){throw new IllegalStateException("Cannot prepare familiar lab",e);}
var rules=level.getGameRules();
rules.set(GameRules.SPAWN_MOBS,false,server);rules.set(GameRules.SPAWN_MONSTERS,false,server);
rules.set(GameRules.MOB_GRIEFING,false,server);rules.set(GameRules.KEEP_INVENTORY,true,server);
rules.set(GameRules.ADVANCE_TIME,false,server);rules.set(GameRules.ADVANCE_WEATHER,false,server);
rules.set(GameRules.RANDOM_TICK_SPEED,0,server);rules.set(GameRules.FIRE_SPREAD_RADIUS_AROUND_PLAYER,0,server);
rules.set(GameRules.SHOW_ADVANCEMENT_MESSAGES,false,server);
level.setRespawnData(LevelData.RespawnData.of(Level.OVERWORLD,new BlockPos(0,81,-10),0,10));
owner=server;clear();
for(var e:level.getEntitiesOfClass(Entity.class,AREA,e->e.entityTags().contains(PROP)))e.discard();
refill();resetMeter();
label(level,new Vec3(0,85,-12),text("welcome"));
for(var rarity:CompanionType.Rarity.values())label(level,Vec3.atBottomCenterOf(chest(rarity)).add(0,2.2,0),rarity.label());
label(level,new Vec3(7.5,84,-6.5),text("spawn"));label(level,new Vec3(11.5,84,-6.5),text("clear"));
label(level,new Vec3(-11.5,84,-6.5),text("reset"));
meter=label(level,new Vec3(-11.5,85,4.5),text("meter",0,"0.00","0.00",Component.empty()));
daylight();
SanctuaryMod.LOGGER.info("FAMILIARS259_LAB_READY world={} eggs={} hostiles={} seed=259 layout=1",world,CompanionType.values().length,HOSTILES.size());
}
private static void build(ServerLevel level) {
for(int x=-24;x<=24;x++)for(int z=-16;z<=24;z++) {
var floor=x<0?Blocks.SMOOTH_STONE:Blocks.STONE_BRICKS;
if(z< -6)floor=Blocks.POLISHED_ANDESITE;
if(x%8==0&&z%8==0)floor=Blocks.SEA_LANTERN;
block(level,new BlockPos(x,80,z),floor);
if(x==-24||x==24||z==-16||z==24)for(int y=81;y<=84;y++)block(level,new BlockPos(x,y,z),y==81?Blocks.DEEPSLATE_BRICKS:Blocks.GLASS);
}
block(level,new BlockPos(0,80,-10),Blocks.SEA_LANTERN);
for(int x=-14;x<=-10;x++)for(int z=2;z<=6;z++)block(level,new BlockPos(x,80,z),x==-12&&z==4?Blocks.TARGET:Blocks.CONCRETE.yellow());
for(int x=10;x<=14;x++)for(int z=8;z<=12;z++)block(level,new BlockPos(x,80,z),Blocks.CONCRETE.red());
for(var rarity:CompanionType.Rarity.values())block(level,chest(rarity),Blocks.CHEST);
for(var button:List.of(SPAWN_BUTTON,CLEAR_BUTTON,RESET_BUTTON)){
block(level,button.below(),Blocks.DEEPSLATE_BRICKS);
level.setBlockAndUpdate(button,Blocks.STONE_BUTTON.defaultBlockState().setValue(ButtonBlock.FACE,AttachFace.FLOOR));
}
// Roof shades the undead while leaving room for projectiles and ordinary airborne familiars.
for(int x=-23;x<=23;x++)for(int z=-5;z<=23;z++)block(level,new BlockPos(x,89,z),Blocks.TINTED_GLASS);
}
static BlockPos chest(CompanionType.Rarity rarity){return new BlockPos(-16+rarity.ordinal()*8,81,-12);}
private static void refill() {
for(var rarity:CompanionType.Rarity.values()) {
if(!(owner.overworld().getBlockEntity(chest(rarity)) instanceof ChestBlockEntity container))throw new IllegalStateException("Missing familiar chest");
int slot=0;
for(var type:CompanionType.values())if(type.rarity==rarity){
var egg=StarterCompanions.egg(type.id());
if(!(egg.getItem() instanceof SpawnEggItem))throw new IllegalStateException("Missing native familiar egg: "+type.id());
if(container.getItem(slot).isEmpty())container.setItem(slot,egg);
slot++;
}
}
}
static boolean spawn(ServerPlayer player) {
if(owner==null||player.level()!=owner.overworld()||!AREA.contains(player.position()))return false;
prune();
if(enemies.size()>=128){player.sendOverlayMessage(text("limit"));return false;}
int index=player.getRandom().nextInt(lastType<0?HOSTILES.size():HOSTILES.size()-1);if(lastType>=0&&index>=lastType)index++;lastType=index;
var type=HOSTILES.get(index);var level=owner.overworld();
// Prefer an unoccupied cell: repeated presses produce a crowd, not stacked entities.
Mob mob=type.create(level,EntitySpawnReason.COMMAND);if(mob==null)return false;
boolean placed=false;
for(int attempt=0;attempt<40;attempt++){
mob.snapTo(SPAWN.getX()+.5+player.getRandom().nextInt(13)-6,81,SPAWN.getZ()+.5+player.getRandom().nextInt(13)-6);
if(level.noCollision(mob)){placed=true;break;}
}
if(!placed){mob.discard();player.sendOverlayMessage(text("crowded"));return false;}
mob.addTag(ENEMY);mob.addTag("sanctuary:no_familiar_training");mob.setPersistenceRequired();mob.setCanPickUpLoot(false);
if(mob instanceof Zombie zombie)zombie.setBaby(false);
if(mob instanceof net.minecraft.world.entity.monster.skeleton.AbstractSkeleton)mob.setItemSlot(EquipmentSlot.MAINHAND,new ItemStack(Items.BOW));
if(mob instanceof net.minecraft.world.entity.monster.illager.Pillager)mob.setItemSlot(EquipmentSlot.MAINHAND,new ItemStack(Items.CROSSBOW));
if(type==EntityTypes.WITHER_SKELETON||type==EntityTypes.PIGLIN_BRUTE||type==EntityTypes.VINDICATOR)mob.setItemSlot(EquipmentSlot.MAINHAND,new ItemStack(Items.IRON_AXE));
mob.setItemSlot(EquipmentSlot.HEAD,new ItemStack(Items.CARVED_PUMPKIN));
mob.setTarget(player);
var attackTarget=net.minecraft.world.entity.ai.memory.MemoryModuleType.ATTACK_TARGET;
if(mob.getBrain().checkMemory(attackTarget,net.minecraft.world.entity.ai.memory.MemoryStatus.REGISTERED))mob.getBrain().setMemory(attackTarget,player);
if(!level.addFreshEntity(mob))return false;
enemies.put(mob.getUUID(),mob);player.sendOverlayMessage(text("spawned",type.getDescription(),enemies.size()));return true;
}
static void clear() {
enemies.values().forEach(Entity::discard);enemies.clear();
if(owner!=null)for(var e:owner.overworld().getEntitiesOfClass(Entity.class,AREA,e->e.entityTags().contains(ENEMY)
|| e instanceof Mob mob&&mob!=dummy&&mob.getType().getCategory()==MobCategory.MONSTER
|| e instanceof net.minecraft.world.entity.projectile.Projectile))e.discard();
}
private static void prune(){enemies.values().removeIf(e->{if(e.isRemoved()||!e.isAlive())return true;if(!AREA.contains(e.position())){e.discard();return true;}return false;});}
private static void resetMeter(){hits=0;last=0;total=0;shownHits=-1;attacker=Component.empty();}
private static void tick() {
if(dummy==null||dummy.isRemoved()){
dummy=new PunchingZombie(owner.overworld());dummy.snapTo(Vec3.atBottomCenterOf(DUMMY));owner.overworld().addFreshEntity(dummy);
}
// Native evoker summons and slime children belong to the same disposable fight.
for(var mob:owner.overworld().getEntitiesOfClass(Mob.class,AREA,e->e!=dummy&&e.getType().getCategory()==MobCategory.MONSTER)){
if(!enemies.containsKey(mob.getUUID())){
if(enemies.size()>=128){mob.discard();continue;}
mob.addTag(ENEMY);mob.addTag("sanctuary:no_familiar_training");enemies.put(mob.getUUID(),mob);
}
}
prune();
if(owner.getTickCount()%20==0){refill();daylight();}
if(meter!=null&&shownHits!=hits){meter.message(text("meter",hits,String.format(Locale.ROOT,"%.2f",last),String.format(Locale.ROOT,"%.2f",total),attacker));shownHits=hits;}
FamiliarLabChecks.tick(owner);
}
private static void daylight() {
RealtimeMode.off(owner);
var level=owner.overworld();
var clock=owner.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.WORLD_CLOCK).getOrThrow(net.minecraft.world.clock.WorldClocks.OVERWORLD);
owner.clockManager().getInstance(clock).loadFrom(new net.minecraft.world.clock.ClockState(6000,0,1,true));owner.clockManager().setTotalTicks(clock,6000);
level.environmentAttributes().invalidateTickCache();level.updateSkyBrightness();
level.getWeatherData().setRaining(false);level.getWeatherData().setThundering(false);level.setRainLevel(0);level.setThunderLevel(0);
}
private static final class RealtimeMode {
static void off(MinecraftServer server){try{fr.koka.sanctuary.realtime.RealtimeService.setMode(server,false);fr.koka.sanctuary.weather.WeatherService.setMode(server,false);}catch(Exception e){throw new IllegalStateException(e);}}
}
static void enter(ServerPlayer p) {
p.teleportTo(owner.overworld(),.5,81,-9.5,Set.of(),0,10,false);p.setGameMode(GameType.SURVIVAL);
var progress=ProgressEffects.get(p);
if(progress!=null){
for(var skill:List.of("vitality","hunger","inventory"))while(progress.rank(skill)<ProgressRecord.cap(skill))progress=progress.buy(skill,0);
p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);ProgressionService.send(p,"");
}
var companion=CompanionService.record(p);if(companion!=null&&!companion.unlocked())CompanionService.save(p,companion.purchase(0,CompanionService.now()));
p.setHealth(p.getMaxHealth());p.getFoodData().setFoodLevel(20);
if(!p.entityTags().contains(PROP)){
p.getInventory().add(new ItemStack(Items.IRON_SWORD));p.getInventory().add(new ItemStack(Items.COOKED_BEEF,64));p.getInventory().add(new ItemStack(Items.NAME_TAG,16));
p.setItemSlot(EquipmentSlot.CHEST,new ItemStack(Items.IRON_CHESTPLATE));p.setItemSlot(EquipmentSlot.LEGS,new ItemStack(Items.IRON_LEGGINGS));p.setItemSlot(EquipmentSlot.FEET,new ItemStack(Items.IRON_BOOTS));p.addTag(PROP);
}
p.sendSystemMessage(text("help"));
}
private static Component text(String key,Object...args){return Component.translatable("sanctuary_test.familiars."+key,args);}
private static void block(ServerLevel level,BlockPos pos,Block block){level.setBlockAndUpdate(pos,block.defaultBlockState());}
private static LabText label(ServerLevel level,Vec3 position,Component message){
var display=new LabText(level);display.message(message);display.setPos(position);display.addTag(PROP);level.addFreshEntity(display);return display;
}
private static final class LabText extends Display.TextDisplay {
LabText(ServerLevel level){super(EntityTypes.TEXT_DISPLAY,level);}
@Override public boolean shouldBeSaved(){return false;}
void message(Component message){
var tag=new CompoundTag();tag.put("text",ComponentSerialization.CODEC.encodeStart(NbtOps.INSTANCE,message).getOrThrow());
tag.putString("billboard","center");tag.putInt("line_width",300);tag.putInt("background",0xC0182532);tag.putBoolean("shadow",true);
var brightness=new CompoundTag();brightness.putInt("block",15);brightness.putInt("sky",15);tag.put("brightness",brightness);
// Update display fields without loading base entity position, UUID or tags.
readAdditionalSaveData(TagValueInput.create(ProblemReporter.DISCARDING,level().registryAccess(),tag));
}
}
static PunchingZombie dummy(){return dummy;}
static long hits(){return hits;}
static int enemyCount(){prune();return enemies.size();}
static Collection<Mob> enemies(){return List.copyOf(enemies.values());}
static final class PunchingZombie extends Zombie {
PunchingZombie(ServerLevel level){
super(EntityTypes.ZOMBIE,level);setNoAi(true);setNoGravity(true);setPersistenceRequired();setBaby(false);
getAttribute(Attributes.MAX_HEALTH).setBaseValue(1024);setHealth(getMaxHealth());
getAttribute(Attributes.KNOCKBACK_RESISTANCE).setBaseValue(1);
addTag(PROP);addTag("sanctuary:no_familiar_training");
setCustomName(text("dummy"));setCustomNameVisible(true);
var helmet=new ItemStack(Items.CARVED_PUMPKIN);setItemSlot(EquipmentSlot.HEAD,helmet);
}
@Override public boolean shouldBeSaved(){return false;}
@Override protected void actuallyHurt(ServerLevel level,DamageSource source,float amount){
float before=getHealth();super.actuallyHurt(level,source,amount);float lost=before-getHealth();
if(lost>0){hits++;last=lost;total+=lost;attacker=source.getEntity()==null?Component.empty():source.getEntity().getDisplayName();}
// Prevent death even for a lethal hit; ordinary hits keep their native health delta until the tick ends.
if(getHealth()<=0)setHealth(getMaxHealth());
}
@Override public void tick(){super.tick();setHealth(getMaxHealth());setPos(Vec3.atBottomCenterOf(DUMMY));setDeltaMovement(Vec3.ZERO);clearFire();}
}
}
@@ -0,0 +1,121 @@
package fr.koka.sanctuarytest;
import com.mojang.authlib.GameProfile;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.familiar.FamiliarBattle;
import fr.koka.sanctuary.inventory.AccessoryAccess;
import fr.koka.sanctuary.progression.*;
import java.net.*;
import java.util.*;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.minecraft.core.Direction;
import net.minecraft.network.*;
import net.minecraft.network.protocol.*;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.*;
import net.minecraft.server.network.CommonListenerCookie;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import net.minecraft.world.phys.*;
/** Native, opt-in verification in a disposable server; never runs in a human review session. */
final class FamiliarLabChecks {
private static ServerPlayer actor;
private static int started;
private static long before;
private static boolean done;
static void tick(MinecraftServer server) {
if(!Boolean.getBoolean("sanctuary.familiarLabChecks")||done)return;
try {
if(actor==null){
if(server.getTickCount()<40)return;
var profile=new GameProfile(UUID.randomUUID(),"Lab_Familiar");
var session=ProgressionService.session(server);
session.inhabitants.create(profile.id(),"labfamiliar","Laboratoire",session.inhabitants.available().getFirst());
actor=new ServerPlayer(server,server.overworld(),profile,ClientInformation.createDefault());
server.getPlayerList().placeNewPlayer(new SinkConnection(),actor,CommonListenerCookie.createInitial(profile,false));
actor.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());
actor.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(actor,ProgressRecord.INITIAL);
FamiliarLab.enter(actor);
verifyFixtures(server);
AccessoryAccess.of(actor).setItem(0,new ItemStack(Items.WOLF_SPAWN_EGG));
actor.teleportTo(server.overworld(),-12.5,81,.5,Set.of(),0,0,false);
actor.tickCount=((actor.tickCount/10)+1)*10;FamiliarService.tick(actor);CompanionService.tick(actor);
require(FamiliarService.active(actor)!=null,"Equipped wolf spawns through the production familiar service");
FamiliarLab.dummy().setInvulnerableTime(0);
before=FamiliarLab.hits();FamiliarBattle.engage(actor,FamiliarLab.dummy(),false);
require(FamiliarBattle.target(actor)==FamiliarLab.dummy(),"Explicit attack accepts the damage target; active="+FamiliarBattle.active(actor)+" enemy="+FamiliarBattle.enemy(actor,FamiliarLab.dummy())+" alive="+FamiliarLab.dummy().isAlive()+" removed="+FamiliarLab.dummy().isRemoved()+" invulnerable="+FamiliarLab.dummy().isInvulnerable()+" pet-distance="+FamiliarService.active(actor).distanceTo(FamiliarLab.dummy()));
started=server.getTickCount();return;
}
if(FamiliarLab.hits()>before&&FamiliarLab.dummy().getLastHurtByMob()==FamiliarService.active(actor)){
require(FamiliarLab.dummy().isAlive(),"Real familiar attacks do not kill the dummy");
require(!FamiliarBattle.trainingTarget(FamiliarLab.dummy()),"Dummy cannot train familiar mastery");
FamiliarLab.clear();
server.getPlayerList().remove(actor);actor=null;done=true;
SanctuaryMod.LOGGER.info("FAMILIARS259_CHECKS_PASS eggs=88 lethal-damage=pass fixed-target=pass buttons=pass random-hostiles=pass cleanup=pass wolf-native-attack=pass");
server.halt(false);
} else if(server.getTickCount()-started>400)throw new AssertionError("Wolf did not land a native attack on the target; hits="+FamiliarLab.hits()+" before="+before+" pet="+FamiliarService.active(actor)+" target="+FamiliarBattle.target(actor)+" order="+FamiliarBattle.state(actor).order+" health="+FamiliarLab.dummy().getHealth()+" bond="+FamiliarBattle.bond(actor));
else if((server.getTickCount()-started)%80==0)SanctuaryMod.LOGGER.info("FAMILIARS259_CHECK_PROGRESS pet={} position={} target={} hits={} before={} cast={} active={}",FamiliarService.active(actor),FamiliarService.active(actor)==null?null:FamiliarService.active(actor).position(),FamiliarBattle.target(actor),FamiliarLab.hits(),before,FamiliarBattle.state(actor).cast,FamiliarBattle.active(actor));
}catch(Throwable error){
done=true;SanctuaryMod.LOGGER.error("FAMILIARS259_CHECKS_FAIL",error);server.halt(false);
}
}
private static void verifyFixtures(MinecraftServer server) {
var level=server.overworld();var types=new HashSet<String>();
for(var rarity:CompanionType.Rarity.values()){
var chest=(ChestBlockEntity)level.getBlockEntity(FamiliarLab.chest(rarity));
for(int slot=0;slot<chest.getContainerSize();slot++)if(!chest.getItem(slot).isEmpty()){
var egg=chest.getItem(slot);require(egg.getItem() instanceof SpawnEggItem,"Catalog contains native spawn eggs");
types.add(SpawnEggItem.getType(egg).toShortString());
}
}
require(types.size()==88&&types.contains("ender_dragon")&&types.contains("creeper"),"All 88 familiar species including specials are selectable");
var dummy=FamiliarLab.dummy();require(dummy!=null&&dummy.isNoAi()&&!dummy.isInvulnerable(),"Dummy is immobile and accepts real damage");
long initial=FamiliarLab.hits();
for(float damage:new float[]{2,4,2000,Float.MAX_VALUE}){
dummy.setInvulnerableTime(0);require(dummy.hurtServer(level,actor.damageSources().playerAttack(actor),damage),"Native damage succeeds");
require(dummy.isAlive()&&!dummy.isRemoved(),"Even lethal attacks preserve the same target");
}
require(FamiliarLab.hits()==initial+4,"One counter increment per accepted hit");
dummy.setDeltaMovement(3,2,4);dummy.tick();
require(dummy.position().equals(Vec3.atBottomCenterOf(FamiliarLab.DUMMY))&&dummy.getDeltaMovement().equals(Vec3.ZERO),"Target returns to its anchor without knockback drift");
var untagged=EntityTypes.COW.create(level,net.minecraft.world.entity.EntitySpawnReason.COMMAND);untagged.setPos(0,81,20);level.addFreshEntity(untagged);
var selected=new HashSet<String>();String previous="";
for(int i=0;i<60;i++){
FamiliarLab.clear();require(FamiliarLab.spawn(actor),"Hostile button can be used repeatedly");
var mob=FamiliarLab.enemies().iterator().next();var id=mob.getType().toShortString();
require(!id.equals(previous)&&!mob.isNoAi()&&mob.getTarget()==actor,"Random enemy changes species and retains attacking AI: "+id+" after "+previous+" target="+mob.getTarget()+" mode="+actor.gameMode.getGameModeForPlayer());
require(mob.entityTags().contains("sanctuary:no_familiar_training"),"Summons are excluded from mastery");
selected.add(id);previous=id;
}
require(selected.size()>8,"Repeated summons cover a varied hostile roster");FamiliarLab.clear();
require(!untagged.isRemoved()&&dummy.isAlive()&&FamiliarLab.enemyCount()==0,"Cleanup removes only laboratory enemies");untagged.discard();
actor.teleportTo(level,7.5,81,-8.5,Set.of(),0,0,false);actor.tickCount+=10;
var hit=new BlockHitResult(Vec3.atCenterOf(FamiliarLab.SPAWN_BUTTON),Direction.UP,FamiliarLab.SPAWN_BUTTON,false);
UseBlockCallback.EVENT.invoker().interact(actor,level,InteractionHand.MAIN_HAND,hit);
require(FamiliarLab.enemyCount()==1,"Real use-block callback spawns exactly one enemy");
actor.tickCount+=10;actor.teleportTo(level,11.5,81,-8.5,Set.of(),0,0,false);
hit=new BlockHitResult(Vec3.atCenterOf(FamiliarLab.CLEAR_BUTTON),Direction.UP,FamiliarLab.CLEAR_BUTTON,false);
UseBlockCallback.EVENT.invoker().interact(actor,level,InteractionHand.MAIN_HAND,hit);
require(FamiliarLab.enemyCount()==0,"Cleanup button works through its callback");
}
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
private static final class SinkConnection extends Connection {
private PacketListener listener;
SinkConnection(){super(PacketFlow.SERVERBOUND);}
@Override public <T extends PacketListener> void setupInboundProtocol(ProtocolInfo<T> protocol,T listener){this.listener=listener;}
@Override public PacketListener getPacketListener(){return listener;}
@Override public void flushChannel(){}
@Override public void send(Packet<?> packet){}
@Override public void send(Packet<?> packet,io.netty.channel.ChannelFutureListener listener){}
@Override public void send(Packet<?> packet,io.netty.channel.ChannelFutureListener listener,boolean flush){}
@Override public boolean isConnected(){return true;}
@Override public boolean isConnecting(){return false;}
@Override public boolean isMemoryConnection(){return true;}
@Override public SocketAddress getRemoteAddress(){return new InetSocketAddress("127.0.0.1",0);}
}
}
@@ -0,0 +1,26 @@
package fr.koka.sanctuarytest;
import java.util.List;
import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.Component;
/** Complete only this disposable review's character form with a laboratory identity. */
final class FamiliarVisit259 {
static void register() {
if(!Boolean.getBoolean("sanctuary.familiarLab"))return;
ScreenEvents.AFTER_INIT.register((mc,screen,w,h)->{
if(!(screen instanceof fr.koka.sanctuary.client.HelloWorldScreen))return;
if(!mc.gameDirectory.toPath().toAbsolutePath().normalize().toString().contains("/build/familiars-259/"))throw new IllegalStateException("Expected familiar review client directory");
mc.execute(()->{
if(mc.gui.screen()!=screen)return;
var enter=new KeyEvent(257,0,0);
for(var suffix:List.of("$ColorButton","$StarterSlot"))for(var child:screen.children())
if(child instanceof Button b&&child.getClass().getName().endsWith(suffix)&&b.active){b.onPress(enter);break;}
for(var child:screen.children())if(child instanceof EditBox box&&box.getMessage().getString().equals(Component.translatable("sanctuary.starter.name").getString()))box.setValue("Laboratoire");
for(var child:screen.children())if(child instanceof Button b&&b.active&&b.getMessage().getString().equals(fr.koka.sanctuary.progression.ProgressionService.label("enter").getString())){b.onPress(enter);break;}
});
});
}
}
@@ -0,0 +1,220 @@
package fr.koka.sanctuarytest;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.insects.*;
import fr.koka.sanctuary.testing.QuickTestWorld;
import java.nio.file.*;
import java.util.*;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.event.lifecycle.v1.*;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.commands.Commands;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.nbt.*;
import net.minecraft.network.chat.*;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.*;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.entity.npc.villager.Villager;
import net.minecraft.world.item.*;
import net.minecraft.world.level.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import net.minecraft.world.level.gamerules.GameRules;
import net.minecraft.world.level.storage.*;
import net.minecraft.world.phys.*;
/** A disposable, explicitly launched world. Seasons are bypassed only on its unsaved specimens. */
public final class InsectLab {
public static final String WORLD="insects-flat-246";
private static final String TAG="sanctuary_insects_lab246";
private static final int FLOOR=80, PITCH=14, COLUMNS=6;
private static final List<Exhibit> EXHIBITS=new ArrayList<>();
private static final Map<UUID,Integer> SELECTED=new HashMap<>();
private static MinecraftServer owner;
private static boolean reported;
private static final class Exhibit {
final InsectCatalog.Species species;
final BlockPos center;
InsectSpawner.Site site;
InsectEntity insect;
long returnAt;
Exhibit(int index) {species=InsectCatalog.ALL.get(index);center=new BlockPos(7+index%COLUMNS*PITCH,FLOOR+1,7+index/COLUMNS*PITCH);}
}
private InsectLab() { }
static void register() {
ServerLifecycleEvents.SERVER_STARTED.register(server->{
if(Boolean.getBoolean("sanctuary.insectsLab"))prepare(server);
});
ServerPlayConnectionEvents.JOIN.register((handler,sender,server)->{
if(server==owner)server.execute(()->enter(handler.player));
});
ServerTickEvents.END_SERVER_TICK.register(server->{if(server==owner)tick(server.overworld());});
ServerLifecycleEvents.SERVER_STOPPED.register(server->{if(server==owner){owner=null;EXHIBITS.clear();SELECTED.clear();reported=false;}});
CommandRegistrationCallback.EVENT.register((dispatcher,registries,environment)->{
var root=dispatcher.register(Commands.literal("insectes").requires(s->s.getServer()==owner)
.executes(c->visit(c.getSource().getPlayerOrException(),0))
.then(Commands.argument("numero",IntegerArgumentType.integer(1,47)).executes(c->visit(c.getSource().getPlayerOrException(),IntegerArgumentType.getInteger(c,"numero")-1)))
.then(Commands.literal("suivant").executes(c->step(c.getSource().getPlayerOrException(),1)))
.then(Commands.literal("precedent").executes(c->step(c.getSource().getPlayerOrException(),-1)))
.then(Commands.literal("observer").executes(c->{c.getSource().getPlayerOrException().setGameMode(GameType.SPECTATOR);return 1;}))
.then(Commands.literal("jouer").executes(c->{var p=c.getSource().getPlayerOrException();p.setGameMode(GameType.ADVENTURE);return visit(p,SELECTED.getOrDefault(p.getUUID(),0));}))
.then(Commands.literal("retour").executes(c->visit(c.getSource().getPlayerOrException(),0)))
.then(Commands.literal("reset").executes(c->{for(var e:EXHIBITS){if(e.insect!=null)e.insect.discard();e.insect=null;e.returnAt=0;}return 1;})));
dispatcher.register(Commands.literal("insects").redirect(root));
});
}
private static void prepare(MinecraftServer server) {
var world=server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
if(!world.getFileName().toString().equals(WORLD) || !world.toString().contains("/build/insects-246/") || !QuickTestWorld.active(server))
throw new IllegalStateException("Insect lab requires its dedicated development directory and flat world");
var level=server.overworld();
// Only this disposable laboratory keeps its 48 exhibition chunks loaded.
for(int x=0;x<=5;x++)for(int z=-1;z<=7;z++){level.getChunk(x,z);level.setChunkForced(x,z,true);}
var marker=world.resolve("insects-lab-246.txt");
try {
if(!Files.exists(marker)) {
for(var pos:BlockPos.betweenClosed(new BlockPos(0,FLOOR,-8),new BlockPos(83,FLOOR+7,111)))
if(!level.getBlockState(pos).isAir())throw new IllegalStateException("Scene requires empty air above a fresh flat world: "+pos);
Files.writeString(marker,"preparing\n",StandardOpenOption.CREATE_NEW);
floor(level);
Files.writeString(marker,"ready-v2\n");
} else if(!Files.readString(marker).equals("ready-v2\n") || !level.getBlockState(new BlockPos(0,FLOOR,0)).is(Blocks.SMOOTH_STONE))
throw new IllegalStateException("Incomplete insect lab preserved; choose a fresh run name");
}catch(java.io.IOException e){throw new IllegalStateException("Cannot prepare insect lab",e);}
var rules=level.getGameRules();
rules.set(GameRules.SPAWN_MOBS,false,server);rules.set(GameRules.SPAWN_MONSTERS,false,server);
rules.set(GameRules.ADVANCE_TIME,false,server);rules.set(GameRules.ADVANCE_WEATHER,false,server);
rules.set(GameRules.RANDOM_TICK_SPEED,0,server);rules.set(GameRules.KEEP_INVENTORY,true,server);
rules.set(GameRules.SEND_COMMAND_FEEDBACK,false,server);rules.set(GameRules.SHOW_ADVANCEMENT_MESSAGES,false,server);
rules.set(GameRules.FALL_DAMAGE,false,server);
level.setRespawnData(LevelData.RespawnData.of(Level.OVERWORLD,new BlockPos(7,81,3),0,15));
daylight(level);
// The same map can be reopened. Only its tagged props are recreated; its blocks stay intact.
for(var e:level.getEntitiesOfClass(Entity.class,new AABB(0,80,-8,84,90,112),e->e.entityTags().contains(TAG)))e.discard();
InsectSpawner.inspectionLevel(level);
EXHIBITS.clear();for(int i=0;i<47;i++){var e=new Exhibit(i);props(level,e,i);EXHIBITS.add(e);}
label(level,new Vec3(21,84,-4),Component.translatable("sanctuary_test.insects.welcome"));
specimenChest(level,new BlockPos(5,81,-3),0,27);
specimenChest(level,new BlockPos(9,81,-3),27,47);
owner=server;reported=false;
SanctuaryMod.LOGGER.info("INSECTS246_LAB_READY world={} exhibits=47 calendar-bypass=development-only",world);
}
private static void floor(ServerLevel level) {
for(int x=0;x<=83;x++)for(int z=-8;z<=111;z++) {
boolean path=z<0 || x%PITCH<2 || z%PITCH<2;
block(level,new BlockPos(x,FLOOR,z),path?Blocks.SMOOTH_STONE:Blocks.GRASS_BLOCK);
if(x%PITCH==1&&z%PITCH==1)block(level,new BlockPos(x,FLOOR,z),Blocks.SEA_LANTERN);
}
}
private static void props(ServerLevel level,Exhibit e,int index) {
var center=e.center;var support=center.below();Direction face=Direction.UP;
Vec3 position=Vec3.atBottomCenterOf(center).add(0,.02,0);
switch(e.species.habitat()) {
case TREE,PALM_TREE -> {
var log=e.species.habitat()==InsectCatalog.Habitat.PALM_TREE?Blocks.JUNGLE_LOG:Blocks.OAK_LOG;
for(int y=0;y<4;y++)block(level,center.above(y),log);
support=center.above(1);face=Direction.NORTH;
position=Vec3.atCenterOf(support).add(0,0,-.515);
}
case FLOWERS,FLOWER,WHITE_FLOWER,RAIN_FLOWER -> {
var flower=e.species.id().equals("peacock")?Blocks.CORNFLOWER:e.species.id().equals("tiger_butterfly")?Blocks.POPPY:Blocks.WHITE_TULIP;
block(level,center,flower);support=center;
position=new Vec3(center.getX()+.5,center.getY()+InsectSpawner.surfaceHeight(level,center,e.species),center.getZ()+.5);
if(e.species.flies())position=position.add(0,.6,0);
}
case WATER -> {
for(int x=-2;x<=2;x++)for(int z=-2;z<=2;z++) {
block(level,support.offset(x,-1,z),Blocks.STONE_BRICKS);block(level,support.offset(x,0,z),Blocks.WATER);
}
position=new Vec3(center.getX()+.5,support.getY()+level.getFluidState(support).getHeight(level,support)+.01,center.getZ()+.5);
}
case SNOW -> {for(int x=-1;x<=1;x++)for(int z=-1;z<=1;z++)block(level,support.offset(x,0,z),Blocks.SNOW_BLOCK);}
case LIGHT -> {block(level,support,Blocks.SEA_LANTERN);position=position.add(0,.6,0);}
case ROTTEN_FOOD -> {
var food=new ItemEntity(level,center.getX()+.5,center.getY()+.15,center.getZ()+.5,new ItemStack(Items.ROTTEN_FLESH));
food.setNeverPickUp();food.setUnlimitedLifetime();food.addTag(TAG);level.addFreshEntity(food);
if(e.species.flies())position=position.add(0,.6,0);
}
case VILLAGER -> {
// The village host can walk inside its small enclosure; the production flea follows it.
for(int d=-3;d<=3;d++)for(var p:List.of(center.offset(d,0,-3),center.offset(d,0,3),center.offset(-3,0,d),center.offset(3,0,d)))block(level,p,Blocks.OAK_FENCE);
var host=new Villager(EntityTypes.VILLAGER,level);host.snapTo(center.getX()+.5,center.getY(),center.getZ()+.5);
host.setPermanentlyInvulnerable(true);host.addTag(TAG);level.addFreshEntity(host);position=host.position().add(.2,1.1,0);
}
case ROCK -> block(level,support,Blocks.STONE);
case UNDERGROUND -> block(level,support,Blocks.DIRT);
case AIR -> position=position.add(0,.6,0);
case GROUND -> { }
}
e.site=new InsectSpawner.Site(support,face,position);
// Newly queued item/villager props enter the native entity lookup on the first server tick.
if(e.species.habitat()!=InsectCatalog.Habitat.ROTTEN_FOOD && e.species.habitat()!=InsectCatalog.Habitat.VILLAGER
&& !InsectSpawner.supportsInspection(level,e.species,support,face))throw new IllegalStateException("Invalid exhibit habitat: "+e.species.id());
var name=Component.literal(String.format("%02d · ",index+1)).append(Component.translatable("item.sanctuary.insect_"+e.species.id()));
name.append("\n"+e.species.id()).append("\n").append(Component.translatable("sanctuary.insects.habitat."+e.species.habitat().name().toLowerCase(Locale.ROOT)));
label(level,new Vec3(center.getX()+.5,84.5,center.getZ()+4),name);
}
private static void label(ServerLevel level,Vec3 pos,Component text) {
var display=new Display.TextDisplay(EntityTypes.TEXT_DISPLAY,level);
var tag=new CompoundTag();tag.put("text",ComponentSerialization.CODEC.encodeStart(NbtOps.INSTANCE,text).getOrThrow());
tag.putString("billboard","center");tag.putInt("line_width",240);tag.putInt("background",0xB0182532);tag.putBoolean("shadow",true);
var brightness=new CompoundTag();brightness.putInt("block",15);brightness.putInt("sky",15);tag.put("brightness",brightness);
display.load(TagValueInput.create(ProblemReporter.DISCARDING,level.registryAccess(),tag));display.setPos(pos);display.addTag(TAG);level.addFreshEntity(display);
}
private static void specimenChest(ServerLevel level,BlockPos pos,int first,int last) {
block(level,pos,Blocks.CHEST);
if(level.getBlockEntity(pos) instanceof ChestBlockEntity chest)for(int i=first;i<last;i++)chest.setItem(i-first,new ItemStack(Insects.specimen(InsectCatalog.ALL.get(i))));
}
private static void tick(ServerLevel level) {
if(level.getGameTime()%20!=0)return;
daylight(level);
long now=level.getGameTime();int alive=0;
for(var e:EXHIBITS) {
if(e.insect!=null&&!e.insect.isRemoved()){alive++;continue;}
if(e.insect!=null){e.insect=null;e.returnAt=now+100;}
if(now<e.returnAt || !level.hasChunkAt(e.site.support()))continue;
// Inspection bypasses calendar and rain, keeping the real supports and movement.
if(!InsectSpawner.supportsInspection(level,e.species,e.site.support(),e.site.face()))continue;
var insect=new InsectEntity(Insects.TYPE,level);insect.place(e.species,e.site.support(),e.site.face(),e.site.position());
insect.inspect();insect.addTag(TAG);if(level.addFreshEntity(insect)){e.insect=insect;alive++;}
}
if(alive==47&&!reported){reported=true;SanctuaryMod.LOGGER.info("INSECTS246_LAB_POPULATION_PASS unique-species=47 props=47 time={} rain={} daylight={}",level.getOverworldClockTime(),level.getRainLevel(1),level.isBrightOutside());}
}
private static void daylight(ServerLevel level) {
var server=level.getServer();
// Reapply after startup callbacks too: real time and weather may initialize later.
try {fr.koka.sanctuary.realtime.RealtimeService.setMode(server,false);fr.koka.sanctuary.weather.WeatherService.setMode(server,false);}
catch(Exception e){throw new IllegalStateException("Cannot set laboratory daylight",e);}
var clock=server.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.WORLD_CLOCK).getOrThrow(net.minecraft.world.clock.WorldClocks.OVERWORLD);
var state=server.clockManager().getInstance(clock).packState();
if(state.totalTicks()!=6000 || state.partialTick()!=0 || state.rate()!=1 || !state.paused()) {
server.clockManager().getInstance(clock).loadFrom(new net.minecraft.world.clock.ClockState(6000,0,1,true));
// The native setter broadcasts the clock; SavedData.setDirty alone only saves it.
server.clockManager().setTotalTicks(clock,6000);
}
level.environmentAttributes().invalidateTickCache();level.updateSkyBrightness();
level.getWeatherData().setRaining(false);level.getWeatherData().setThundering(false);
level.getWeatherData().setClearWeatherTime(24000);level.getWeatherData().setRainTime(0);level.getWeatherData().setThunderTime(0);
level.setRainLevel(0);level.setThunderLevel(0);
}
private static void enter(ServerPlayer player) {
player.setGameMode(GameType.ADVENTURE);
player.getAbilities().invulnerable=true;player.onUpdateAbilities();player.getFoodData().setFoodLevel(20);
if(!player.getInventory().contains(stack->stack.is(Insects.NET)))player.getInventory().add(new ItemStack(Insects.NET));
visit(player,0);
}
private static int step(ServerPlayer p,int delta){return visit(p,Math.floorMod(SELECTED.getOrDefault(p.getUUID(),0)+delta,47));}
private static int visit(ServerPlayer player,int index) {
var e=EXHIBITS.get(index);SELECTED.put(player.getUUID(),index);
float pitch=(float)Math.toDegrees(Math.atan2(e.center.getY()+player.getEyeHeight()-e.site.position().y,e.site.position().z-(e.center.getZ()-4.5)));
player.teleportTo(owner.overworld(),e.center.getX()+.5,e.center.getY(),e.center.getZ()-4.5,Set.of(),0,pitch,false);
return 1;
}
private static void block(ServerLevel level,BlockPos pos,Block block){level.setBlockAndUpdate(pos,block.defaultBlockState());}
}
@@ -0,0 +1,42 @@
package fr.koka.sanctuarytest;
import java.util.List;
import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.Component;
/** Opens only the explicitly requested disposable review session, skipping its identity form. */
final class InsectVisit246 {
private static boolean verified;
static void register() {
if(!Boolean.getBoolean("sanctuary.insectsLab"))return;
ScreenEvents.AFTER_INIT.register((mc,screen,w,h)->{
if(!(screen instanceof fr.koka.sanctuary.client.HelloWorldScreen))return;
if(!mc.gameDirectory.toPath().toAbsolutePath().normalize().toString().contains("/build/insects-246/"))throw new IllegalStateException("Expected insect review client directory");
mc.execute(()->{
if(mc.gui.screen()!=screen)return;
var enter=new KeyEvent(257,0,0);
for(var suffix:List.of("$ColorButton","$StarterSlot"))for(var child:screen.children())
if(child instanceof Button b&&child.getClass().getName().endsWith(suffix)&&b.active){b.onPress(enter);break;}
for(var child:screen.children())if(child instanceof EditBox box&&box.getMessage().getString().equals(Component.translatable("sanctuary.starter.name").getString()))box.setValue("Inspection");
for(var child:screen.children())if(child instanceof Button b&&b.active&&b.getMessage().getString().equals(fr.koka.sanctuary.progression.ProgressionService.label("enter").getString())){b.onPress(enter);break;}
});
});
net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents.END_CLIENT_TICK.register(mc->{
if(verified || mc.player==null || mc.level==null || mc.gui.screen()!=null)return;
if(mc.level.getOverworldClockTime()%24000!=6000 || mc.level.getRainLevel(1)!=0 || !mc.level.isBrightOutside())return;
if(!mc.gameDirectory.toPath().toAbsolutePath().normalize().toString().contains("/build/insects-246/"))throw new IllegalStateException("Wrong insect review directory");
for(var species:fr.koka.sanctuary.insects.InsectCatalog.ALL)verifySprite(mc,fr.koka.sanctuary.insects.Insects.specimen(species),"insect/"+species.id());
verifySprite(mc,fr.koka.sanctuary.insects.Insects.NET,"item/insect_net");
verified=true;fr.koka.sanctuary.SanctuaryMod.LOGGER.info("INSECTS246_CLIENT_OPEN inventory-sprites=47 net-sprite=pass time=6000 weather=clear daylight=pass world={} player={}",mc.level.dimension().identifier(),mc.player.blockPosition());
});
}
private static void verifySprite(net.minecraft.client.Minecraft mc,net.minecraft.world.item.Item item,String path) {
var state=new net.minecraft.client.renderer.item.ItemStackRenderState();
mc.getItemModelResolver().updateForLiving(state,new net.minecraft.world.item.ItemStack(item),net.minecraft.world.item.ItemDisplayContext.GUI,mc.player);
var material=state.pickParticleMaterial(net.minecraft.util.RandomSource.create(0));
if(state.isEmpty() || material==null || !material.sprite().contents().name().equals(fr.koka.sanctuary.SanctuaryMod.id(path)))throw new IllegalStateException("Missing baked insect review sprite: "+path);
if(material.sprite().contents().width()!=16 || material.sprite().contents().height()!=16)throw new IllegalStateException("Expected 16x16 insect review sprite: "+path);
}
}
@@ -14,6 +14,8 @@ public final class QuickTestClient implements ClientModInitializer {
AscentView184.register();
PhotoVisit193.register();
LabVisit200.register();
InsectVisit246.register();
FamiliarVisit259.register();
ClientConfigurationNetworking.registerGlobalReceiver(QuickTestMod.Mode.TYPE,(payload,context)->
context.client().execute(()->IntroClient.setQuickTestIntro(payload.quick())));
ScreenEvents.AFTER_INIT.register((minecraft,screen,width,height)->{
@@ -19,6 +19,8 @@ public final class QuickTestMod implements ModInitializer {
DuoLab.register();
HordeLab.register();
PalaceLab.register();
InsectLab.register();
FamiliarLab.register();
WorldgenDiagnostics.register();
PayloadTypeRegistry.clientboundConfiguration().register(Mode.TYPE,Mode.CODEC);
ServerConfigurationConnectionEvents.CONFIGURE.register((handler,server)->{
+93 -4
View File
@@ -8,6 +8,27 @@ base { archivesName = 'sanctuary' }
apply from: 'gradle/origin-resources.gradle'
configurations { soundPhysicsArtifact { transitive = false } }
def soundPhysics = "maven.modrinth:qyVF9oeo:${rootProject.sound_physics_artifact}"
tasks.register('verifySoundPhysicsArtifact') {
inputs.files(configurations.soundPhysicsArtifact)
inputs.property('sha512', rootProject.sound_physics_sha512)
doLast {
def artifact = configurations.soundPhysicsArtifact.singleFile
def actual = java.security.MessageDigest.getInstance('SHA-512').digest(artifact.bytes).encodeHex().toString()
if (actual != rootProject.sound_physics_sha512) {
throw new GradleException('Sound Physics Remastered artifact hash mismatch')
}
copy {
from(artifact)
into(rootProject.layout.buildDirectory.dir('external-mods'))
rename { "sound-physics-remastered-fabric-${rootProject.sound_physics_version}.jar" }
}
}
}
tasks.named('compileJava') { dependsOn('verifySoundPhysicsArtifact') }
tasks.named('jar') { dependsOn('verifySoundPhysicsArtifact') }
def clientRenderTests = providers.gradleProperty('sanctuaryClientTests')
.map { it.toBoolean() }.getOrElse(false)
@@ -27,6 +48,12 @@ if (clientRenderTests) {
java.srcDir('src/clientGametest/java')
}
tasks.named('runClientGameTest') {
def heapGb = providers.gradleProperty('sanctuaryClientHeapGb')
if (heapGb.isPresent()) {
int memory = Integer.parseInt(heapGb.get())
if (memory < 1 || memory > 32) throw new GradleException('Client test heap must be 1..32 GiB')
jvmArgs("-Xmx${memory}G")
}
def graphicsBackend = providers.gradleProperty('sanctuaryClientGraphicsBackend')
if (graphicsBackend.isPresent()) {
args('--graphicsBackend', graphicsBackend.get())
@@ -36,7 +63,16 @@ if (clientRenderTests) {
if (improvedTransparency.isPresent()) {
systemProperty('sanctuary.test.improvedTransparency', improvedTransparency.get())
}
systemProperty('sanctuary.test.external257Integration', providers.gradleProperty('sanctuaryExternal257Integration').getOrElse('false'))
systemProperty('sanctuary.test.ssr', providers.gradleProperty('sanctuarySsrClientTests').getOrElse('false'))
systemProperty('sanctuary.test.shaderPerformance254', providers.gradleProperty('sanctuaryShaderPerformance254').getOrElse('false'))
systemProperty('sanctuary.test.shaderMovingSun254', providers.gradleProperty('sanctuaryShaderMovingSun254').getOrElse('false'))
systemProperty('sanctuary.test.leaf255', providers.gradleProperty('sanctuaryLeaf255ClientTests').getOrElse('false'))
systemProperty('sanctuary.test.leaf255PerformanceOnly', providers.gradleProperty('sanctuaryLeaf255PerformanceOnly').getOrElse('false'))
systemProperty('sanctuary.test.shaderOptions255', providers.gradleProperty('sanctuaryShaderOptions255ClientTests').getOrElse('false'))
systemProperty('sanctuary.test.shaderProfiles254', providers.gradleProperty('sanctuaryShaderProfiles254ClientTests').getOrElse('false'))
systemProperty('sanctuary.test.zenith258', providers.gradleProperty('sanctuaryZenith258ClientTests').getOrElse('false'))
systemProperty('sanctuary.test.anchor256Only', providers.gradleProperty('sanctuaryAnchor256ClientTests').getOrElse('false'))
systemProperty('sanctuary.test.water144', providers.gradleProperty('sanctuaryWater144ClientTests').getOrElse('false'))
systemProperty('sanctuary.test.water144ExtrasOnly', providers.gradleProperty('sanctuaryWater144ExtrasOnly').getOrElse('false'))
systemProperty('sanctuary.test.water144FunctionalOnly', providers.gradleProperty('sanctuaryWater144FunctionalOnly').getOrElse('false'))
@@ -46,6 +82,7 @@ if (clientRenderTests) {
systemProperty('sanctuary.test.directional141', providers.gradleProperty('sanctuaryDirectional141ClientTests').getOrElse('false'))
systemProperty('sanctuary.test.colored140', providers.gradleProperty('sanctuaryColored140ClientTests').getOrElse('false'))
systemProperty('sanctuary.test.noVsync', providers.gradleProperty('sanctuaryClientNoVsync').getOrElse('false'))
systemProperty('sanctuary.test.windowed', providers.gradleProperty('sanctuaryClientWindowed').getOrElse('false'))
systemProperty('sanctuary.test.quick', providers.gradleProperty('sanctuaryQuickTests').getOrElse('false'))
systemProperty('sanctuary.test.introVisualOnly', providers.gradleProperty('sanctuaryIntroVisualOnly').getOrElse('false'))
systemProperty('sanctuary.test.atlasBaseline', providers.gradleProperty('sanctuaryAtlasBaseline').getOrElse('false'))
@@ -62,6 +99,7 @@ tasks.register('cleanGameTestWorld', Delete) {
tasks.named('runGameTest') {
dependsOn('cleanGameTestWorld')
systemProperty('sanctuary.test.village306.planOnly', providers.gradleProperty('sanctuaryVillagePlanOnly').getOrElse('false'))
systemProperty('sanctuary.test.waystonesLarge239', providers.gradleProperty('sanctuaryWaystonesLarge239Tests').getOrElse('false'))
systemProperty('sanctuary.test.atlasOnly', providers.gradleProperty('sanctuaryAtlasOnly').getOrElse('false'))
systemProperty('sanctuary.test.atlasSeeds', providers.gradleProperty('sanctuaryAtlasSeeds').getOrElse('0,42,-7228211907433324401'))
systemProperty('sanctuary.test.atlasRevision', providers.gradleProperty('sanctuaryAtlasRevision').getOrElse('working-tree'))
@@ -113,6 +151,15 @@ tasks.named('runGameTest') {
}
tasks.named('processGametestResources') {
def external257 = providers.gradleProperty('sanctuaryExternal257ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryExternal257ClientTests', external257)
if (external257) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.External257ClientChecks') }
def menuMusic253 = providers.gradleProperty('sanctuaryMenuMusic253ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryMenuMusic253ClientTests', menuMusic253)
if (menuMusic253) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.sound.MenuMusic253ClientChecks') }
def sound252 = providers.gradleProperty('sanctuarySound252ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySound252ClientTests', sound252)
if (sound252) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.sound.SoundPhysics252ClientChecks') }
def expansion199 = providers.gradleProperty('sanctuaryExpansion199ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryExpansion199ClientTests', expansion199)
if (expansion199) {
@@ -187,6 +234,9 @@ 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 performance256 = providers.gradleProperty('sanctuaryPerformance256ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPerformance256ClientTests', performance256)
if (performance256) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Performance256ClientChecks') }
def gems126Client = providers.gradleProperty('sanctuaryGems126ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryGems126ClientTests', gems126Client)
@@ -228,7 +278,7 @@ tasks.named('processGametestResources') {
def villageOnly = providers.gradleProperty('sanctuaryVillageOnly').map { it.toBoolean() }.getOrElse(false)
def focused = providers.gradleProperty('sanctuaryFocusedTests').getOrElse('').split(',').findAll { !it.isEmpty() }
def focusClasses = [solo232: 'Solo232GameTests', diagonal231: 'Diagonal231GameTests', diagonal230: 'Diagonal230GameTests', diagonal229: 'Diagonal229GameTests', diagonal228: 'Diagonal228GameTests', diagonal227: 'Diagonal227GameTests', diagonal226: 'Diagonal226GameTests', diagonal225: 'Diagonal225GameTests', east224: 'East224GameTests', east223: 'East223GameTests', rock222: 'YellowRock222GameTests', west221: 'West221GameTests', west220: 'West220GameTests', west219: 'West219GameTests', west218: 'West218GameTests', west217: 'West217GameTests', south216: 'South216GameTests', relays215: 'Relays215GameTests', south214: 'South214GameTests', south213: 'South213GameTests', south212: 'South212GameTests', south211: 'South211GameTests', south210: 'South210GameTests', south209: 'South209GameTests', north208: 'North208GameTests', north207: 'North207GameTests', north206: 'North206GameTests', north205: 'North205GameTests', base204: 'Base204GameTests', palace173: 'Palace173GameTests', horde172: 'Horde172GameTests', horde171: 'Horde171GameTests', horde170: 'Horde170GameTests', horde169: 'Horde169GameTests', horde168: 'Horde168GameTests', horde167: 'Horde167GameTests', 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']
def focusClasses = [familiar260: 'Familiar260GameTests', census: 'CensusGameTestsTerrain', insects245: 'Insect245GameTests', map244: 'Atlas244GameTests', ziplines242: 'Zipline242GameTests', waystones239: 'Waystone239GameTests', waystonesLarge239: 'WaystoneLarge239GameTests', forgerie238: 'Forgerie238GameTests', aptitudes236: 'Aptitudes236GameTests', solo232: 'Solo232GameTests', diagonal231: 'Diagonal231GameTests', diagonal230: 'Diagonal230GameTests', diagonal229: 'Diagonal229GameTests', diagonal228: 'Diagonal228GameTests', diagonal227: 'Diagonal227GameTests', diagonal226: 'Diagonal226GameTests', diagonal225: 'Diagonal225GameTests', east224: 'East224GameTests', east223: 'East223GameTests', rock222: 'YellowRock222GameTests', west221: 'West221GameTests', west220: 'West220GameTests', west219: 'West219GameTests', west218: 'West218GameTests', west217: 'West217GameTests', south216: 'South216GameTests', relays215: 'Relays215GameTests', south214: 'South214GameTests', south213: 'South213GameTests', south212: 'South212GameTests', south211: 'South211GameTests', south210: 'South210GameTests', south209: 'South209GameTests', north208: 'North208GameTests', north207: 'North207GameTests', north206: 'North206GameTests', north205: 'North205GameTests', base204: 'Base204GameTests', palace173: 'Palace173GameTests', horde172: 'Horde172GameTests', horde171: 'Horde171GameTests', horde170: 'Horde170GameTests', horde169: 'Horde169GameTests', horde168: 'Horde168GameTests', horde167: 'Horde167GameTests', 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']
focused.each { if (!focusClasses.containsKey(it)) throw new GradleException('Unknown focused native test: ' + it) }
inputs.property('focusedNative', focused)
def yellowRock222Client = providers.gradleProperty('sanctuaryYellowRock222ClientTests').map { it.toBoolean() }.getOrElse(false)
@@ -337,6 +387,25 @@ tasks.named('processGametestResources') {
def pause233Client = providers.gradleProperty('sanctuaryPause233ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPause233ClientTests', pause233Client)
if (pause233Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Pause233ClientChecks') }
def aptitudes236Client = providers.gradleProperty('sanctuaryAptitudes236ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('aptitudes236Client', aptitudes236Client)
if (aptitudes236Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Aptitudes236ClientChecks') }
def ziplines242Client = providers.gradleProperty('sanctuaryZiplines242ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryZiplines242ClientTests', ziplines242Client)
if (ziplines242Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Zipline242ClientChecks') }
def waystones239Client = providers.gradleProperty('sanctuaryWaystones239ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('waystones239Client', waystones239Client)
if (waystones239Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Waystone239ClientChecks') }
def insects245Client = providers.gradleProperty('sanctuaryInsects245ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('insects245Client', insects245Client)
if (insects245Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Insect245ClientChecks') }
def map244Client = providers.gradleProperty('sanctuaryMap244ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('map244Client', map244Client)
if (map244Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Map244ClientChecks') }
def map243Client = providers.gradleProperty('sanctuaryMap243ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('map243Client', map243Client)
if (map243Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Map243ClientChecks') }
def story234Client = providers.gradleProperty('sanctuaryStory234ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryStory234ClientTests', story234Client)
if (story234Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Story234ClientChecks') }
@@ -355,6 +424,9 @@ tasks.named('processGametestResources') {
def plans072Client = providers.gradleProperty('sanctuaryPlans072ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('plans072Client', plans072Client)
if (plans072Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Plans072ClientChecks') }
def familiar260Client = providers.gradleProperty('sanctuaryFamiliar260ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('familiar260Client', familiar260Client)
if (familiar260Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Familiar260ClientChecks') }
def familiar071Client = providers.gradleProperty('sanctuaryFamiliar071ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('familiar071Client', familiar071Client)
if (familiar071Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Familiar071ClientChecks') }
@@ -414,6 +486,9 @@ tasks.named('processGametestResources') {
def starterNameClient = providers.gradleProperty('sanctuaryStarterNameClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('starterNameClient', starterNameClient)
if (starterNameClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Starter052ClientChecks') }
def hello262Client = providers.gradleProperty('sanctuaryHello262ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryHello262ClientTests', hello262Client)
if (hello262Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Hello262ClientChecks') }
def head050Client = providers.gradleProperty('sanctuaryHead050ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('head050Client', head050Client)
if (head050Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Head050ClientChecks') }
@@ -563,10 +638,22 @@ tasks.named('processGametestResources') {
}
dependencies {
// Optional adapter is compiled against the exact pack version, never embedded.
compileOnly 'maven.modrinth:AANobbMI:bAZQdGpg'
if (providers.gradleProperty('sanctuarySodiumClientTests').map { it.toBoolean() }.getOrElse(false)) {
runtimeOnly 'maven.modrinth:AANobbMI:bAZQdGpg'
}
soundPhysicsArtifact(soundPhysics)
compileOnly(soundPhysics) { transitive = false }
if (providers.gradleProperty('sanctuarySoundPhysicsRuntime').map { it.toBoolean() }.getOrElse(true)) {
runtimeOnly(soundPhysics) { transitive = false }
}
implementation 'org.mariadb.jdbc:mariadb-java-client:3.5.10'
include('org.mariadb.jdbc:mariadb-java-client:3.5.10')
implementation project(':jei')
include project(':jei')
compileOnly project(':jei')
if (providers.gradleProperty('sanctuaryJeiRuntime').map { it.toBoolean() }.getOrElse(true)) {
runtimeOnly project(':jei')
}
minecraft "com.mojang:minecraft:${rootProject.minecraft_version}"
implementation "net.fabricmc:fabric-loader:${rootProject.loader_version}"
implementation "net.fabricmc.fabric-api:fabric-api:${rootProject.fabric_api_version}"
@@ -600,7 +687,8 @@ processResources {
// Normalize launcher pre-release IDs when targeting one; final releases retain their exact ID.
def values = [version: project.version, minecraft_version: rootProject.minecraft_version,
minecraft_dependency_version: rootProject.minecraft_version.replace('-pre-', '-pre.'),
loader_version: rootProject.loader_version, fabric_api_version: rootProject.fabric_api_version, jei_version: rootProject.jei_version]
loader_version: rootProject.loader_version, fabric_api_version: rootProject.fabric_api_version, jei_version: rootProject.jei_version,
sound_physics_version: rootProject.sound_physics_version]
inputs.properties(values)
filesMatching('fabric.mod.json') { expand(values) }
}
@@ -608,6 +696,7 @@ processResources {
tasks.named('jar') {
from(rootProject.file('LICENSE')) { rename { 'LICENSE_sanctuary' } }
from(rootProject.file('THIRD_PARTY_NOTICES.md'))
from(rootProject.file('mods/sound-physics-remastered')) { into 'licenses/sound-physics-remastered' }
}
tasks.register('worldgenSmoke', JavaExec) {
@@ -49,7 +49,7 @@ public final class Access166ClientChecks implements FabricClientGameTest {
if(!running.get()||!(m.gui.screen() instanceof HelloWorldScreen screen))return;
if(!filled.getAndSet(true)){
welcome.set(screen);
check(screen.children().stream().anyMatch(child->child instanceof Button b && b.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals("sanctuary.progression.get_code")),"Registration link is offered by configured server");
check(screen.children().stream().anyMatch(child->child instanceof Button b && b.getMessage().getContents() instanceof TranslatableContents t && (t.getKey().equals("sanctuary.progression.get_code")||t.getKey().equals("sanctuary.progression.get_code_short"))),"Registration link is offered by configured server");
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());
for(var child:screen.children())if(child instanceof EditBox box && box.getMessage().getContents() instanceof TranslatableContents text){
@@ -0,0 +1,105 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.recipes.RecipeService;
import fr.koka.sanctuary.food.FoodKnowledgeService;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
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.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.network.chat.contents.TranslatableContents;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Native Progression widgets and real server requests in a disposable Vulkan world. */
public final class Aptitudes236ClientChecks implements FabricClientGameTest {
private static final Map<String,String> TITLES=Map.ofEntries(
Map.entry("names","sanctuary.progression.names"),Map.entry("atlas","sanctuary.progression.atlas"),
Map.entry("sorting","sanctuary.progression.sorting"),Map.entry("swimming","sanctuary.progression.swimming"),
Map.entry("resting","sanctuary.progression.resting"),Map.entry("active_bond","sanctuary.companion.link"),
Map.entry("familiar_study","sanctuary.companion.study"),Map.entry("catalogue","sanctuary.recipes.catalogue"),
Map.entry("zoom","sanctuary.zoom.title"),Map.entry("enchanting","sanctuary.progression.enchanting"),Map.entry("alchemy","sanctuary.progression.alchemy"),Map.entry("smithing","sanctuary.progression.smithing"));
@Override public void runTest(ClientGameTestContext c) {
var intro=new AtomicBoolean(true);fr.koka.sanctuary.testing.QuickTestWorld.enable();
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get() && m.gui.screen() instanceof HelloWorldScreen screen) {
IntroClient.setQuickTestIntro(true);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());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Forgerie238 "+UUID.randomUUID());s.setSeed("238");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("minecraft: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.setExperienceLevels(100);ProgressionService.send(p,"");});
c.waitFor(m->m.player.experienceLevel==100,200);
c.runOnClient(m->{m.gui.setScreen(new ProgressionScreen(new PauseScreen(true)));check(!CraftAptitudes.learned(m.player,"enchanting") && !CraftAptitudes.learned(m.player,"alchemy") && !CraftAptitudes.learned(m.player,"smithing"),"New workstations start locked");});
for(String craft:List.of("enchanting","alchemy","smithing")) {
c.runOnClient(m->{var button=afterTitle(m.gui.screen(),TITLES.get(craft));check(key(button).equals("sanctuary.progression.unlock_cost")&&button.active,"Locked craft offers its normal purchase button");button.onPress(enter());});
c.waitFor(m->CraftAptitudes.learned(m.player,craft) && key(afterTitle(m.gui.screen(),TITLES.get(craft))).equals("sanctuary.progression.aptitude_acquired"),200);
}
c.runOnClient(m->check(m.player.experienceLevel==88,"Three UI purchases cost twelve levels"));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setExperienceLevels(100);
for(String target:ProgressRecord.APTITUDES)check(ProgressionService.purchase(p,target,ProgressEffects.get(p).revision()),"Server acquires "+target);
check(CompanionService.buy(p),"Active Bond bought");check(CompanionStudy.buy(p),"Familiar Study bought");
check(RecipeService.buy(p),"Catalogue bought");check(ZoomService.buy(p),"Zoom bought");check(FoodKnowledgeService.buy(p),"Food Knowledge bought");
CompanionService.send(p,"");ProgressionService.send(p,"");
});
c.runOnClient(m->m.gui.setScreen(null));
c.waitFor(m->ProgressionClient.progress.names() && CompanionClient.studied && ZoomClient.learned() && RecipeClient.knowledge!=null && RecipeClient.knowledge.learned() && FoodKnowledgeClient.ready(),300);
c.waitTicks(30);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setAttached(AptitudeService.PREFERENCES,2044);ProgressionService.send(p,"");check(p.getAttached(AptitudeService.PREFERENCES)==0,"Legacy preferences are normalized without losing purchases");});
c.runOnClient(m->{m.player.setAttached(AptitudeService.PREFERENCES,2044);for(String target:AptitudePreferences.PERMANENT)check(AptitudeClient.enabled(target),"Client ignores legacy disabled bits for "+target);});
String purchaseProof=server.computeOnServer(s->s.getPlayerList().getPlayers().getFirst().getAttached(ProgressionService.RECORD));
int xp=server.computeOnServer(s->s.getPlayerList().getPlayers().getFirst().experienceLevel);
for(String language:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new ProgressionScreen(new PauseScreen(true)));});
c.waitTicks(3);
c.runOnClient(m->{var screen=m.gui.screen();check(screen.isPauseScreen(),"Progression retains Pause semantics");layout(screen);var titles=widgets(screen).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).map(Aptitudes236ClientChecks::key).toList();
int previous=-1;for(String theme:List.of("survival","inventory_crafting","familiars","exploration","crafts")){int index=titles.indexOf("sanctuary.progression.theme_"+theme);check(index>previous,"All five themes are present in order");previous=index;}
check(has(screen,"sanctuary.progression.aptitude_permanent"),"Food Knowledge is visibly permanent");
check(key(afterTitle(screen,"sanctuary.food_knowledge.title")).equals("sanctuary.food_knowledge.learned") && !afterTitle(screen,"sanctuary.food_knowledge.title").active,"Food Knowledge has no activation switch");
for(var entry:TITLES.entrySet()){var button=afterTitle(screen,entry.getValue());boolean toggle=AptitudePreferences.TOGGLEABLE.contains(entry.getKey());
check(key(button).equals(toggle?"sanctuary.progression.aptitude_enabled":"sanctuary.progression.aptitude_acquired") && button.active==toggle,"Only World Map and Mob Names have switches: "+entry.getKey());check(m.font.width(button.getMessage())+8<=button.getWidth(),"Aptitude label fits without clipping");}
});
shot(c,"top-"+language+"-"+scale);
for(String target:AptitudePreferences.TOGGLEABLE) {
c.runOnClient(m->{var screen=m.gui.screen();var button=afterTitle(screen,TITLES.get(target));reveal(screen,button);check(button.active,"Toggle is ready: "+target);button.onPress(enter());});
c.waitFor(m->!AptitudeClient.enabled(target) && key(afterTitle(m.gui.screen(),TITLES.get(target))).equals("sanctuary.progression.aptitude_disabled"),200);
check(server.computeOnServer(s->!AptitudeService.enabled(s.getPlayerList().getPlayers().getFirst(),target)),"UI choice is authoritative: "+target);
if(target.equals("atlas")) {
c.runOnClient(m->{m.gui.setScreen(new PauseScreen(true));pauseLayout(m.gui.screen(),false);});shot(c,"pause-map-disabled-"+language+"-"+scale);
c.runOnClient(m->m.gui.setScreen(new ProgressionScreen(new PauseScreen(true))));
}
c.runOnClient(m->afterTitle(m.gui.screen(),TITLES.get(target)).onPress(enter()));
c.waitFor(m->AptitudeClient.enabled(target) && key(afterTitle(m.gui.screen(),TITLES.get(target))).equals("sanctuary.progression.aptitude_enabled"),200);
}
c.runOnClient(m->{m.gui.setScreen(new PauseScreen(true));pauseLayout(m.gui.screen(),true);});shot(c,"pause-map-enabled-"+language+"-"+scale);
c.runOnClient(m->{m.gui.setScreen(new ProgressionScreen(new PauseScreen(true)));reveal(m.gui.screen(),afterTitle(m.gui.screen(),TITLES.get("smithing")));});
shot(c,"crafts-"+language+"-"+scale);
c.runOnClient(m->{var screen=m.gui.screen();var area=aptitudes(screen);area.setScrollAmount(100);});
c.runOnClient(m->{m.getWindow().setWindowed(1344,1008);m.resizeGui();});c.waitTicks(3);
c.runOnClient(m->{check(aptitudes(m.gui.screen()).scrollAmount()==100,"Resize retains aptitude scroll");layout(m.gui.screen());press(m.gui.screen(),"gui.done");check(m.gui.screen() instanceof PauseScreen,"Done returns to Pause");m.gui.setScreen(null);});
}
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(p.experienceLevel==xp && p.getAttached(ProgressionService.RECORD).equals(purchaseProof),"Twelve disable/reactivate pairs preserve XP and purchases");});
System.out.println("FORGERIE238_PASS: Vulkan FR/EN GUI 2/3/4, five themes, two real server toggles, eleven permanent purchases, legacy preferences and merged Pause layout, three native UI purchases, preserved XP/purchases, scroll/resize and Done");
}finally{intro.set(false);}
}
private static void pauseLayout(Screen screen,boolean enabled) {Pause233ClientChecks.pause(screen,enabled);}
private static String key(AbstractWidget w){return w.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
private static Button afterTitle(Screen screen,String title) {
var list=widgets(screen).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).toList();
int index=java.util.stream.IntStream.range(0,list.size()).filter(i->key(list.get(i)).equals(title)).findFirst().orElseThrow(()->new AssertionError("Missing title "+title));
return list.subList(index+1,list.size()).stream().filter(Button.class::isInstance).map(Button.class::cast).findFirst().orElseThrow();
}
private static AbstractScrollArea aptitudes(Screen screen){return widgets(screen).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).max(Comparator.comparingInt(AbstractWidget::getX)).orElseThrow();}
private static void reveal(Screen screen,AbstractWidget widget){var area=aptitudes(screen);area.setScrollAmount(Math.clamp(area.scrollAmount()+widget.getBottom()-area.getBottom()+4,0,area.maxScrollAmount()));}
private static void layout(Screen screen){var area=aptitudes(screen);check(area.getX()>=0&&area.getRight()<=screen.width&&area.getBottom()<screen.height-26,"Aptitudes viewport stays inside native frame");for(var button:widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).toList())check(button.getX()>=0&&button.getRight()<=screen.width,"Buttons remain within the screen horizontally");}
private static void shot(ClientGameTestContext c,String name){var source=c.takeScreenshot("forgerie238-"+name);try{var dir=FabricLoader.getInstance().getGameDir().resolve("../../../../../build/forgerie238-screenshots").normalize();Files.createDirectories(dir);Files.copy(source,dir.resolve(name+".png"),StandardCopyOption.REPLACE_EXISTING);}catch(Exception e){throw new AssertionError(e);}}
}
@@ -48,11 +48,11 @@ public final class Familiar067ClientChecks implements FabricClientGameTest {
p.teleportTo(.5,1,.5);for(int x=-10;x<=10;x++)for(int z=-10;z<=20;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.DIAMOND));p.setItemInHand(InteractionHand.OFF_HAND,new ItemStack(Items.GOLD_INGOT));p.inventoryMenu.broadcastChanges();
});c.waitTicks(12);
c.runOnClient(m->check(orders().getDefaultKey().equals(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_H)),"H is the restored default"));
c.getInput().holdKey(InputConstants.KEY_H);c.waitFor(m->m.gui.screen() instanceof FamiliarOrdersScreen,100);c.getInput().releaseKey(InputConstants.KEY_H);c.waitTicks(5);
server.runOnServer(s->check(player(s).getMainHandItem().is(Items.DIAMOND)&&player(s).getOffhandItem().is(Items.GOLD_INGOT),"H orders must not swap hands"));
c.runOnClient(m->check(orders().getDefaultKey().equals(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_LALT)),"Alt is the unified familiar shortcut"));
c.getInput().holdKey(InputConstants.KEY_LALT);c.waitFor(m->m.gui.screen() instanceof FamiliarOrdersScreen,100);c.getInput().releaseKey(InputConstants.KEY_LALT);c.waitTicks(5);
server.runOnServer(s->check(player(s).getMainHandItem().is(Items.DIAMOND)&&player(s).getOffhandItem().is(Items.GOLD_INGOT),"Alt orders must not swap hands"));
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);c.getInput().pressKey(InputConstants.KEY_F);c.waitTicks(8);c.runOnClient(m->check(m.gui.screen()==null,"F swaps hands without opening orders"));
server.runOnServer(s->check(player(s).getMainHandItem().is(Items.GOLD_INGOT)&&player(s).getOffhandItem().is(Items.DIAMOND),"Native F swaps both hands with default H orders"));
server.runOnServer(s->check(player(s).getMainHandItem().is(Items.GOLD_INGOT)&&player(s).getOffhandItem().is(Items.DIAMOND),"Native F swaps both hands with default Alt orders"));
c.runOnClient(m->check(KeyMapping.get("sanctuary.companion.key").getDefaultKey().equals(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_G)),"Technique remains on G"));
c.runOnClient(m->{orders().setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_K));KeyMapping.resetMapping();});
c.getInput().pressKey(InputConstants.KEY_F);c.waitTicks(8);server.runOnServer(s->check(player(s).getMainHandItem().is(Items.DIAMOND)&&player(s).getOffhandItem().is(Items.GOLD_INGOT),"Native F swap works when orders use another key"));
@@ -109,7 +109,7 @@ public final class Familiar067ClientChecks implements FabricClientGameTest {
}
}
private static ItemStack egg(int size){var stack=new ItemStack(Items.ENDER_DRAGON_SPAWN_EGG);var tag=new CompoundTag();tag.putInt("schema",1);tag.putInt("per_mille",size);CustomData.update(DataComponents.CUSTOM_DATA,stack,t->t.put(FamiliarSize.KEY,tag));return stack;}
private static KeyMapping orders(){return KeyMapping.get("sanctuary.familiar.orders_key");}
private static KeyMapping orders(){return KeyMapping.get("sanctuary.familiar.quick_key");}
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 click(ClientGameTestContext c,int id){c.runOnClient(m->{var e=m.level.getEntity(id);m.gameMode.interact(m.player,e,new EntityHitResult(e,e.getEyePosition()),InteractionHand.MAIN_HAND);});}
@@ -0,0 +1,136 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.familiar.*;
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.KeyMapping;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.DeathScreen;
import net.minecraft.client.gui.screens.inventory.*;
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.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.level.block.Blocks;
import net.minecraft.world.level.gamerules.GameRules;
/** Native keyboard input, inventory tooltips and an actual client-requested respawn. */
public final class Familiar260ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);var old=new AtomicReference<ServerPlayer>();var target=new AtomicReference<Mob>();
var individual=new AtomicReference<UUID>();
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("Familiar261 "+UUID.randomUUID());s.setSeed("261");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.getAbilities().invulnerable=true;p.getInventory().clearContent();p.teleportTo(.5,1,.5);p.fallDistance=0;
FamiliarService.tick(p);FamiliarBattle.cancel(p);FamiliarBattle.release(p);
p.removeAttached(FamiliarBattle.COMBAT_LOCK);p.removeAttached(FamiliarBattle.LAST_FAMILIAR);
for(int x=-12;x<=12;x++)for(int z=-12;z<=12;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
p.level().getGameRules().set(GameRules.KEEP_INVENTORY,true,s);p.level().getGameRules().set(GameRules.SPAWN_MOBS,false,s);
var egg=new ItemStack(Items.WOLF_SPAWN_EGG);
var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",1000);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
var profile=FamiliarBattle.world(s).catalog.forEgg(egg);UUID id;
do{id=UUID.randomUUID();}while(FamiliarPersonality.of(id,profile.id())!=FamiliarPersonality.PACIFIST);
FamiliarBattle.world(s).store.put(FamiliarBond.create(id,profile,0,"client test",p.getUUID()));
individual.set(id);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,FamiliarIdentity.assign(egg,id));p.inventoryMenu.broadcastChanges();
});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check(FamiliarService.active(p)!=null,"Fixture familiar spawned before input: bond="+FamiliarBattle.bond(p)+" lock="+p.getAttachedOrElse(FamiliarBattle.COMBAT_LOCK,0L));target.set(enemy(p));});
c.runOnClient(m->{check(quick().getDefaultKey().equals(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_LALT)),"Quick attack defaults to left Alt");
check(KeyMapping.get("sanctuary.familiar.orders_key")==null,"There is one familiar shortcut and no separate H binding");
check(KeyMapping.get("sanctuary.companion.key").getDefaultKey().equals(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_G)),"Technique keeps G");
m.player.setYRot(0);m.player.setXRot(0);
});c.waitTicks(5);
c.getInput().pressKey(InputConstants.KEY_LALT);c.waitTicks(8);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==target.get(),"Native Alt input designates the aimed hostile: pos="+p.position()+" look="+p.getLookAngle()+" active="+FamiliarBattle.active(p)+" pet="+FamiliarService.active(p)+" request="+FamiliarBattle.state(p).lastRequest+" tick="+p.tickCount+" aim="+FamiliarTechniques.aim(p,24,e->FamiliarBattle.enemy(p,e)));});
c.runOnClient(m->check(m.gui.screen()==null,"Quick attack stays in gameplay"));
c.getInput().pressKey(InputConstants.KEY_H);c.waitTicks(3);
c.getInput().holdKey(InputConstants.KEY_H);c.waitTicks(12);c.getInput().releaseKey(InputConstants.KEY_H);
c.runOnClient(m->check(m.gui.screen()==null,"Neither tapping nor holding H opens familiar orders"));
int request=server.computeOnServer(s->{var p=player(s);FamiliarBattle.command(p,"protect");return FamiliarBattle.state(p).lastRequest;});
c.getInput().holdKey(InputConstants.KEY_LALT);c.waitTicks(4);
server.runOnServer(s->check(FamiliarBattle.state(player(s)).lastRequest==request,"Holding Alt does not send a quick attack before opening the orders: before="+request+" after="+FamiliarBattle.state(player(s)).lastRequest));
c.waitFor(m->m.gui.screen() instanceof FamiliarOrdersScreen,100);c.getInput().releaseKey(InputConstants.KEY_LALT);c.waitTicks(3);
server.runOnServer(s->check(FamiliarBattle.state(player(s)).lastRequest==request,"Opening and releasing held Alt never sends a quick attack"));
c.runOnClient(m->button(m.gui.screen(),"sanctuary.familiar.protect").onPress(enter()));c.waitTicks(5);
server.runOnServer(s->check(FamiliarBattle.state(player(s)).order==FamiliarBattle.Order.PROTECT,"Order button reaches the server"));
for(String locale:List.of("fr_fr","en_us")){
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new InventoryScreen(m.player));tooltip(m.gui.screen());});
c.getInput().holdKey(InputConstants.KEY_LALT);c.waitTicks(12);
server.runOnServer(s->check(FamiliarBattle.state(player(s)).order==FamiliarBattle.Order.PROTECT,"Alt is ignored while inventory is open"));
c.takeScreenshot("familiar261-inventory-"+locale);c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(10);
c.runOnClient(m->check(m.gui.screen()==null,"Alt already held in inventory does not open orders after closing it"));
c.getInput().releaseKey(InputConstants.KEY_LALT);c.waitTicks(3);
server.runOnServer(s->check(FamiliarBattle.state(player(s)).order==FamiliarBattle.Order.PROTECT,"Releasing that blocked gesture never attacks"));
}
c.runOnClient(m->{quick().setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_K));KeyMapping.resetMapping();});
c.getInput().pressKey(InputConstants.KEY_LALT);c.waitTicks(4);
server.runOnServer(s->check(FamiliarBattle.state(player(s)).order==FamiliarBattle.Order.PROTECT,"Rebinding releases Alt"));
c.getInput().pressKey(InputConstants.KEY_K);c.waitTicks(5);
server.runOnServer(s->check(FamiliarBattle.target(player(s))==target.get(),"Quick attack uses its configured key"));
int configuredRequest=server.computeOnServer(s->{FamiliarBattle.command(player(s),"protect");return FamiliarBattle.state(player(s)).lastRequest;});openOrders(c,InputConstants.KEY_K);
server.runOnServer(s->check(FamiliarBattle.state(player(s)).lastRequest==configuredRequest,"The configured key also opens orders without sending a quick attack"));
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
c.runOnClient(m->{quick().setKey(quick().getDefaultKey());KeyMapping.resetMapping();});
server.runOnServer(s->{var p=player(s);old.set(p);p.tickCount=24000;FamiliarBattle.command(p,"protect");FamiliarBattle.command(p,"refresh");p.getAbilities().invulnerable=false;p.hurtServer(p.level(),p.damageSources().genericKill(),Float.MAX_VALUE);});
c.waitFor(m->m.gui.screen() instanceof DeathScreen,200);c.waitTicks(25);
c.runOnClient(m->button(m.gui.screen(),"deathScreen.respawn").onPress(enter()));
c.waitFor(m->m.player!=null&&m.player.isAlive()&&m.gui.screen()==null&&!IntroSpawnFlash.active(),300);c.waitTicks(15);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);
check(p!=old.get()&&p.getId()==old.get().getId(),"Native respawn creates a new player with the same entity ID");
check(pet!=null&&pet.owner()==p&&FamiliarBattle.bond(p).id().equals(individual.get()),"Kept egg binds its individual to the new life");
p.teleportTo(.5,1,.5);
});c.waitFor(m->m.player.position().distanceToSqr(new net.minecraft.world.phys.Vec3(.5,1,.5))<.1,200);c.waitTicks(15);
server.runOnServer(s->{var p=player(s);
// The pre-death chunk can reload its zombie as a new object; retire the loaded targets.
p.level().getEntitiesOfClass(net.minecraft.world.entity.monster.zombie.Zombie.class,p.getBoundingBox().inflate(12)).forEach(Entity::discard);
target.set(enemy(p));
});c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);});c.waitTicks(5);
openOrders(c,InputConstants.KEY_LALT);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.familiar.protect").onPress(enter()));c.waitTicks(5);
server.runOnServer(s->check(FamiliarBattle.state(player(s)).order==FamiliarBattle.Order.PROTECT,"First post-death order is accepted without waiting for the old clock"));
c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);});c.waitTicks(15);
server.runOnServer(s->{var p=player(s);check(p.tickCount-FamiliarBattle.state(p).lastRequest>=3,"Post-respawn input waits for the native command cooldown");});
c.getInput().pressKey(InputConstants.KEY_LALT);c.waitTicks(8);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==target.get(),"Quick attack works after actual respawn: expected="+target.get()+" actual="+FamiliarBattle.target(p)+" pos="+p.position()+" look="+p.getLookAngle()+" active="+FamiliarBattle.active(p)+" pet="+FamiliarService.active(p)+" request="+FamiliarBattle.state(p).lastRequest+" tick="+p.tickCount+" aim="+FamiliarTechniques.aim(p,24,e->FamiliarBattle.enemy(p,e))+" mayHit="+FamiliarBattle.mayHit(FamiliarService.active(p),target.get()));});
c.waitTicks(100);
server.runOnServer(s->check(target.get().getHealth()<100,"Respawned familiar navigates and deals real damage"));
openOrders(c,InputConstants.KEY_LALT);c.takeScreenshot("familiar261-orders-after-respawn");c.getInput().pressKey(InputConstants.KEY_ESCAPE);
System.out.println("FAMILIAR261_CLIENT_PASS backend=vulkan Alt-tap-quick Alt-hold-orders no-attack-on-hold H-inactive G-technique configurable-key inventory-gating FR/EN-tooltip native-respawn real-familiar-hit");
}finally{intro.set(false);c.getInput().releaseKey(InputConstants.KEY_LALT);c.getInput().releaseKey(InputConstants.KEY_H);c.runOnClient(m->{quick().setKey(quick().getDefaultKey());KeyMapping.resetMapping();});}
}
@SuppressWarnings("unchecked") private static void tooltip(net.minecraft.client.gui.screens.Screen screen){
try{
var inventory=(InventoryScreen)screen;
var hover=AbstractContainerScreen.class.getDeclaredField("hoveredSlot");hover.setAccessible(true);hover.set(screen,inventory.getMenu().getSlot(64));
var method=AbstractContainerScreen.class.getDeclaredMethod("getTooltipFromContainerItem",ItemStack.class);method.setAccessible(true);
var lines=(List<net.minecraft.network.chat.Component>)method.invoke(screen,inventory.getMenu().getSlot(64).getItem());
check(lines.stream().noneMatch(t->t.getContents() instanceof TranslatableContents text&&text.getKey().equals("screen.sanctuary.inventory.familiar")),"Equipped familiar tooltip omits the redundant placement instruction");
check(InventoryEquipmentLabels.label(64)!=null,"Empty familiar slot keeps its placement help");
}catch(ReflectiveOperationException e){throw new AssertionError(e);}
}
private static void openOrders(ClientGameTestContext c,int key){c.getInput().holdKey(key);c.waitFor(m->m.gui.screen() instanceof FamiliarOrdersScreen,100);c.getInput().releaseKey(key);c.waitTicks(3);}
private static Button button(net.minecraft.client.gui.screens.Screen screen,String key){return screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow();}
private static Mob enemy(ServerPlayer p){var mob=net.minecraft.world.entity.EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);mob.setNoAi(true);mob.setPos(.5,1,5.5);mob.getAttribute(Attributes.MAX_HEALTH).setBaseValue(100);mob.setHealth(100);mob.setItemSlot(EquipmentSlot.HEAD,new ItemStack(Items.CARVED_PUMPKIN));p.level().addFreshEntity(mob);return mob;}
private static KeyMapping quick(){return KeyMapping.get("sanctuary.familiar.quick_key");}
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,158 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.companion.CompanionType;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.gametest.Starter052ClientChecks;
import fr.koka.sanctuary.progression.*;
import java.util.*;
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.TitleScreen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
/** Cold configuration UI, all palette pages and server refreshes, then a real named admission. */
public final class Hello262ClientChecks implements FabricClientGameTest {
private static final KeyEvent PRESS=new KeyEvent(InputConstants.KEY_RETURN,0,0);
private static final String BIO="Une habitante qui explore les îles.",NAME="Étoile de lune";
private static final List<Integer> PALETTE=java.util.stream.IntStream.range(0,20).map(i->InhabitantStore.color(42,i)).boxed().toList();
private static final List<StarterCompanions.Choice> CHOICES=List.of("cat","chicken","pig").stream().map(id->new StarterCompanions.Choice(id,CompanionType.Rarity.COMMON)).toList();
private static StarterPayloads.Welcome welcome(List<Integer> colors,String error,boolean locked){return new StarterPayloads.Welcome(new ProgressionPayloads.Hello("Poupoutain",colors,error),locked?BIO:"",locked,CHOICES);}
@Override public void runTest(ClientGameTestContext c){
Starter039TestSupport.manual=true;
try{
c.runOnClient(m->{require(m.player==null&&m.level==null,"Welcome renders on a cold client");m.getWindow().setWindowed(1280,960);});
c.waitTicks(5);
Hello262PreviewChecks.run(c);
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)){
c.runOnClient(m->{
m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
m.options.guiScale().set(scale);m.resizeGui();m.gui.setScreen(new HelloWorldScreen(welcome(PALETTE,"",false),()->{}));
var s=(HelloWorldScreen)m.gui.screen();bounds(s);
require(!button(s,"sanctuary.progression.enter").active,"An explicit colour and familiar are required");
edit(s,"sanctuary.progression.bio").setValue(BIO);edit(s,"sanctuary.starter.name").setValue(NAME);
slots(s).get(1).onPress(PRESS);
var seen=new ArrayList<Integer>();int count=colors(s).size();
require(count==9,"Nine colours per page at every GUI scale");
require(!page(s,false).active,"Previous page disabled at start");
do{
seen.addAll(colors(s).stream().map(b->number(b,"rgb")).toList());
bounds(s);
if(!page(s,true).active)break;
var next=page(s,true);s.setFocused(next);next.onPress(PRESS);
require(s.getFocused()==next,"Keyboard focus remains on the palette arrow while paging");
}while(true);
require(seen.equals(PALETTE),"Every server colour appears once, in order, including the partial final page");
colors(s).getLast().onPress(PRESS);int selected=number(s,"color");
page(s,false).onPress(PRESS);
require(number(s,"color")==selected&&edit(s,"sanctuary.progression.bio").getValue().equals(BIO)&&edit(s,"sanctuary.starter.name").getValue().equals(NAME),"Paging preserves selection and both drafts");
require(button(s,"sanctuary.progression.enter").active,"Selection on another palette page remains valid");
m.resizeGui();s=(HelloWorldScreen)m.gui.screen();
require(number(s,"color")==selected&&edit(s,"sanctuary.starter.name").getValue().equals(NAME),"Resize preserves choices and drafts");
var remaining=PALETTE.subList(0,7);
s.accept(welcome(remaining,"color_taken",false));
require(number(s,"color")==-1&&!button(s,"sanctuary.progression.enter").active,"A colour taken on the server clears only the rejected colour");
require(edit(s,"sanctuary.progression.bio").getValue().equals(BIO)&&edit(s,"sanctuary.starter.name").getValue().equals(NAME)&&value(s,"starter").equals("chicken"),"Server rejection keeps biography, familiar and name");
colors(s).getFirst().onPress(PRESS);bounds(s);
s.accept(welcome(PALETTE,"",false));colors(s).getFirst().onPress(PRESS);
edit(s,"sanctuary.progression.bio").setCursorPosition(0);edit(s,"sanctuary.progression.bio").setHighlightPos(0);bounds(s);
});
c.waitTicks(3);c.takeScreenshot("hello262-"+locale+"-gui"+scale);
if(locale.equals("fr_fr")&&scale==2){
cursor(c,false);c.waitTicks(3);c.takeScreenshot("hello262-player-cursor-left");
var before=c.computeOnClient(m->portraitAngles((HelloWorldScreen)m.gui.screen()));
cursor(c,true);c.waitTicks(3);c.takeScreenshot("hello262-player-cursor-right");
c.runOnClient(m->{var after=portraitAngles((HelloWorldScreen)m.gui.screen());require(before[0]<0&&after[0]>0&&after[1]<before[1]-10,"Native 3D player faces the cursor on both axes without dragging: "+Arrays.toString(before)+" -> "+Arrays.toString(after));});
cursor(c,false);c.waitTicks(3);
}
c.runOnClient(m->{
var s=(HelloWorldScreen)m.gui.screen();s.setAccessSite("https://example.com/register");s.requireAccess(false);
edit(s,"sanctuary.progression.code").setValue("bad");
require(!button(s,"sanctuary.progression.enter").active,"Unrecognized access code blocks entry");bounds(s);
s.prepareAccess(true);s.accept(welcome(PALETTE,"",false));
require(button(s,"sanctuary.progression.enter").active,"Already recognized admission can resume");bounds(s);
});
c.waitTicks(3);c.takeScreenshot("hello262-access-"+locale+"-gui"+scale);
c.runOnClient(m->{
var s=new HelloWorldScreen(new StarterPayloads.Welcome(new ProgressionPayloads.Hello("Poupoutain",PALETTE,""),"",false,
List.of("zombie","camel","chicken").stream().map(id->new StarterCompanions.Choice(id,CompanionType.Rarity.COMMON)).toList()),()->{});
m.gui.setScreen(s);colors(s).getFirst().onPress(PRESS);slots(s).getFirst().onPress(PRESS);edit(s,"sanctuary.starter.name").setValue("Petit Zombie");bounds(s);
});
cursor(c,false);c.waitTicks(3);c.takeScreenshot("hello264-zombie-"+locale+"-gui"+scale);
cursor(c,true);c.waitTicks(3);c.takeScreenshot("hello264-zombie-cursor-right-"+locale+"-gui"+scale);cursor(c,false);
c.runOnClient(m->{var s=(HelloWorldScreen)m.gui.screen();slots(s).get(1).onPress(PRESS);edit(s,"sanctuary.starter.name").setValue("Petit chameau");});
c.waitTicks(3);c.takeScreenshot("hello264-camel-"+locale+"-gui"+scale);
if(locale.equals("fr_fr")&&scale==2){
c.runOnClient(m->{var s=(HelloWorldScreen)m.gui.screen();var egg=slots(s).getFirst();s.setFocused(null);
var w=m.getWindow();double x=egg.getX()+11,y=egg.getY()+11,sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();
m.mouseHandler.onMove(w.handle(),x*sx,y*sy,0,0);m.mouseHandler.handleAccumulatedMovement();});
c.waitTicks(10);c.takeScreenshot("hello264-zombie-hover");cursor(c,false);
}
c.runOnClient(m->{var s=(HelloWorldScreen)m.gui.screen();s.prepareAccess(true);s.accept(welcome(PALETTE,"",false));edit(s,"sanctuary.starter.name").setValue(NAME);slots(s).get(1).onPress(PRESS);});
}
c.runOnClient(m->{
var s=(HelloWorldScreen)m.gui.screen();
int chosen=number(s,"color");
m.options.guiScale().set(2);m.resizeGui();s=(HelloWorldScreen)m.gui.screen();
require(colors(s).size()==9&&number(s,"color")==chosen,"Wider menu keeps nine colours and the selected RGB");
m.options.guiScale().set(4);m.resizeGui();s=(HelloWorldScreen)m.gui.screen();
require(colors(s).size()==9&&number(s,"color")==chosen&&edit(s,"sanctuary.starter.name").getValue().equals(NAME),"Narrow menu keeps nine colours, the draft and selected RGB");
s.accept(welcome(List.of(PALETTE.getLast()),"",true));bounds(s);
require(!colors(s).getFirst().active&&!page(s,true).active&&!page(s,false).active,"Saved identity and singleton palette stay locked");
require(button(s,"sanctuary.progression.enter").active,"Saved identity can finish its familiar choice");
m.gui.setScreen(new HelloWorldScreen(new ProgressionPayloads.Hello("Poupoutain",List.of(PALETTE.getFirst()),""),()->{}));s=(HelloWorldScreen)m.gui.screen();
colors(s).getFirst().onPress(PRESS);bounds(s);
require(button(s,"sanctuary.progression.enter").active,"Identity-only offers require no familiar");
m.gui.setScreen(new HelloWorldScreen(new ProgressionPayloads.Hello("Poupoutain",List.of(),"storage_invalid"),()->{}));bounds((HelloWorldScreen)m.gui.screen());
require(!button((HelloWorldScreen)m.gui.screen(),"sanctuary.progression.enter").active,"Empty offers stay visible and cannot be submitted");
m.gui.setScreen(new TitleScreen(false));
});
}finally{Starter039TestSupport.manual=false;}
var babies=Hello262PreviewChecks.verifyRealBabies();
new Starter052ClientChecks().runTest(c);
require(babies.get(),"Welcome previews match the real native baby forms after admission");
new Access166ClientChecks().runTest(c);
System.out.println("HELLO262_CLIENT_PASS: Vulkan, cold welcome, FR/EN GUI 2/3/4, full palette, access and locked identity, rejection/drafts, real named and code admissions, saved reconnect");
}
private static List<Button> colors(HelloWorldScreen s){return buttons(s,"ColorButton");}
private static List<Button> slots(HelloWorldScreen s){return buttons(s,"StarterSlot");}
private static List<Button> buttons(HelloWorldScreen s,String type){return s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.visible&&b.getClass().getSimpleName().equals(type)).toList();}
private static Button page(HelloWorldScreen s,boolean next){return buttons(s,"ColorPageButton").stream().filter(b->b.getMessage().getString().equals(next?">":"<")).findFirst().orElseThrow();}
private static AbstractWidget widget(HelloWorldScreen s,String key){return s.children().stream().filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->w.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow();}
private static Button button(HelloWorldScreen s,String key){return (Button)widget(s,key);}
private static EditBox edit(HelloWorldScreen s,String key){return (EditBox)widget(s,key);}
private static int number(Object target,String field){return (Integer)value(target,field);}
private static float[] portraitAngles(HelloWorldScreen s){var portrait=s.children().stream().filter(w->w.getClass().getSimpleName().equals("PlayerPortrait")).findFirst().orElseThrow();return new float[]{(Float)value(portrait,"yaw"),(Float)value(portrait,"pitch")};}
private static void cursor(ClientGameTestContext c,boolean bottomRight){c.runOnClient(m->{var w=m.getWindow();double x=bottomRight?w.getGuiScaledWidth()-1:0,y=bottomRight?w.getGuiScaledHeight()-1:0,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();});}
@SuppressWarnings("unchecked") private static <T> T value(Object target,String field){try{var f=target.getClass().getDeclaredField(field);f.setAccessible(true);return (T)f.get(target);}catch(Exception error){throw new AssertionError(error);}}
private static void bounds(HelloWorldScreen s){
var widgets=s.children().stream().filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->w.visible).toList();
for(var w:widgets)require(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Control fits "+s.width+"x"+s.height+": "+w.getMessage().getString());
for(String key:List.of("sanctuary.progression.enter","gui.back")){
var b=button(s,key);require(net.minecraft.client.Minecraft.getInstance().font.width(b.getMessage())<=b.getWidth()-8,"Action label fits without scrolling: "+key);
}
var biography=edit(s,"sanctuary.progression.bio");
for(String hint:List.of("sanctuary.progression.bio_hint","sanctuary.starter.name_hint"))
require(net.minecraft.client.Minecraft.getInstance().font.width(net.minecraft.network.chat.Component.translatable(hint))<=biography.getWidth()-8,"Placeholder fits the hotbar column: "+hint);
var enter=button(s,"sanctuary.progression.enter");var back=button(s,"gui.back");
require(biography.getWidth()==182&&Math.abs(biography.getX()*2+biography.getWidth()-s.width)<=1,"Form uses the centred native hotbar width");
require(enter.getWidth()==biography.getWidth()&&back.getWidth()==biography.getWidth(),"Both actions use the same width as the form");
require(enter.getY()==s.height-26&&back.getBottom()<s.height-33,"Enter occupies the native Done footer; Back stays in content");
if(!slots(s).isEmpty()){
var name=edit(s,"sanctuary.starter.name");
require(name.getX()==biography.getX()&&name.getWidth()==biography.getWidth(),"Name and biography share the hotbar column");
require(slots(s).getFirst().getY()>biography.getBottom()&&slots(s).getLast().getBottom()<name.getY(),"Familiar choices follow biography and precede the name");
for(var egg:slots(s))require(egg.getX()>=biography.getX()&&egg.getRight()<=biography.getRight(),"Egg choices are inside the central column");
}
var portrait=widgets.stream().filter(w->w.getClass().getSimpleName().equals("PlayerPortrait")).findFirst().orElseThrow();
require(portrait.getY()==number(s,"previewY")&&portrait.getHeight()==number(s,"previewHeight"),"Portraits share the same bounds and floor");
for(int i=0;i<widgets.size();i++)for(int j=i+1;j<widgets.size();j++){
var a=widgets.get(i);var b=widgets.get(j);
require(a.getRight()<=b.getX()||b.getRight()<=a.getX()||a.getBottom()<=b.getY()||b.getBottom()<=a.getY(),"Controls do not overlap: "+a.getMessage().getString()+" / "+b.getMessage().getString());
}
}
private static void require(boolean condition,String message){if(!condition)throw new AssertionError(message);}
}
@@ -0,0 +1,106 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.vertex.PoseStack;
import fr.koka.sanctuary.companion.CompanionType;
import java.lang.reflect.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.entity.EntityRenderer;
import net.minecraft.client.renderer.entity.LivingEntityRenderer;
import net.minecraft.client.renderer.entity.state.*;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.*;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.EntitySpawnReason;
import net.minecraft.world.phys.Vec3;
/** Every eligible starter submits a textured native model before item components are bound. */
final class Hello262PreviewChecks {
static final List<CompanionType> TYPES=Arrays.stream(CompanionType.values()).filter(t->t.rarity.ordinal()<2).toList();
@SuppressWarnings({"rawtypes","unchecked"}) static void run(ClientGameTestContext c){
c.runOnClient(m->{
require(m.level==null&&m.player==null,"3D previews precede world admission");
boolean unbound=false;
try{new ItemStack(Items.COW_SPAWN_EGG);}catch(NullPointerException expected){unbound="Components not bound yet".equals(expected.getMessage());}
require(unbound,"Preview is tested in the cold multiplayer component state");
for(var type:TYPES){
var state=HelloFamiliarPreview.state(type.id());
var livingState=(LivingEntityRenderState)state;
if(List.of("cat","chicken","cow","pig","wolf","zombie","turtle").contains(type.id()))require(livingState.isBaby,"Native baby appearance is selected for "+type.id());
if(List.of("spider","skeleton","frog","cod").contains(type.id()))require(!livingState.isBaby,"Species without native baby forms retain their real familiar appearance: "+type.id());
EntityRenderer renderer=m.getEntityRenderDispatcher().getRenderer(state);
var calls=new AtomicInteger();
var collector=(SubmitNodeCollector)Proxy.newProxyInstance(Hello262PreviewChecks.class.getClassLoader(),new Class<?>[]{SubmitNodeCollector.class},(proxy,method,args)->{
if(method.getName().equals("order"))return proxy;
if(method.getName().equals("submitModel"))calls.incrementAndGet();
if(method.isDefault())return InvocationHandler.invokeDefault(proxy,method,args);
return null;
});
var camera=new CameraRenderState();camera.pos=Vec3.ZERO;
renderer.submit(state,new PoseStack(),collector,camera);
require(calls.get()>0,"Native model is submitted for "+type.id());
require(state.boundingBoxHeight>0&&state.boundingBoxWidth>0,"Preview has finite native dimensions: "+type.id());
if(renderer instanceof LivingEntityRenderer living){
var texture=living.getTextureLocation((LivingEntityRenderState)state);
require(m.getResourceManager().getResource(texture).isPresent(),"Preview texture exists for "+type.id()+": "+texture);
}
}
m.options.guiScale().set(2);m.resizeGui();m.gui.setScreen(new Gallery());
});
c.getInput().setCursorPos(0,0);c.waitTicks(4);c.takeScreenshot("hello262-cold-models-left");
float[] left=c.computeOnClient(m->{var state=(LivingEntityRenderState)((Gallery)m.gui.screen()).states.get("cat");return new float[]{state.yRot,state.xRot};});
c.getInput().setCursorPos(640,480);c.waitTicks(4);c.takeScreenshot("hello262-cold-models-right");
c.runOnClient(m->{
var state=(LivingEntityRenderState)((Gallery)m.gui.screen()).states.get("cat");
require(state.yRot<left[0]&&state.xRot>left[1],"Native familiar head follows the cursor horizontally and vertically");
require(m.level==null&&m.player==null,"Model rendering never creates a world or player");
});
c.getInput().setCursorPos(0,0);
System.out.println("HELLO262_MODELS_PASS: "+TYPES.size()+" native starter models with baby forms and textures, unbound item components, 3D mouse tracking");
}
@SuppressWarnings({"rawtypes","unchecked"}) static java.util.concurrent.atomic.AtomicBoolean verifyRealBabies(){
var verified=new java.util.concurrent.atomic.AtomicBoolean();
ClientTickEvents.END_CLIENT_TICK.register(m->{
if(verified.get()||m.player==null||m.level==null)return;
var mismatches=new ArrayList<String>();
for(var type:TYPES){
var preview=(LivingEntityRenderState)HelloFamiliarPreview.state(type.id());
var entity=preview.entityType.create(m.level,EntitySpawnReason.MOB_SUMMONED);
require(entity instanceof Mob,"Familiar is a native mob: "+type.id());
var mob=(Mob)entity;mob.setId(m.player.getId()+1000+type.ordinal());mob.setPos(m.player.position());mob.setBaby(true);
EntityRenderer renderer=m.getEntityRenderDispatcher().getRenderer(mob);
var actual=(LivingEntityRenderState)renderer.createRenderState(mob,1);
if(preview.isBaby!=actual.isBaby)mismatches.add(type.id()+" age "+preview.isBaby+" / "+actual.isBaby);
if(preview.ageScale!=actual.ageScale)mismatches.add(type.id()+" age scale "+preview.ageScale+" / "+actual.ageScale);
if(renderer instanceof LivingEntityRenderer living){
var texture=living.getTextureLocation(actual);var previewTexture=living.getTextureLocation(preview);
if(!texture.equals(previewTexture))mismatches.add(type.id()+" texture "+previewTexture+" / "+texture);
}
}
require(mismatches.isEmpty(),"All previews match native Mob.setBaby(true): "+String.join("; ",mismatches));
verified.set(true);System.out.println("HELLO263_BABIES_PASS: all 44 previews match native Mob.setBaby(true) ages, age scales and textures");
});
return verified;
}
private static final class Gallery extends Screen {
final Map<String,EntityRenderState> states=new HashMap<>();
Gallery(){super(Component.literal("Hello World · 3D previews"));}
@Override public void extractBackground(GuiGraphicsExtractor g,int mx,int my,float delta){extractMenuBackground(g);}
@Override public void extractRenderState(GuiGraphicsExtractor g,int mx,int my,float delta){
super.extractRenderState(g,mx,my,delta);g.centeredText(font,title,width/2,4,0xFFFFFFFF);
int cellWidth=width/8,cellHeight=(height-20)/6;
for(int i=0;i<TYPES.size();i++){
var type=TYPES.get(i);int x=i%8*cellWidth,y=20+i/8*cellHeight;
states.put(type.id(),HelloFamiliarPreview.extract(g,type.id(),x+4,y+2,cellWidth-8,cellHeight-16,mx,my));
g.centeredText(font,type.label(),x+cellWidth/2,y+cellHeight-11,0xFFFFFFFF);
}
}
}
private static void require(boolean condition,String message){if(!condition)throw new AssertionError(message);}
}
@@ -0,0 +1,221 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.insects.*;
import fr.koka.sanctuary.realtime.*;
import java.time.*;
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.core.*;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.Vec3;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Fresh disposable Vulkan world: synchronized sprites, orientations, native sweep, and flight. */
public final class Insect245ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
fr.koka.sanctuary.testing.QuickTestWorld.enable();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) {
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());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Insects245-"+UUID.randomUUID());s.setSeed("245");s.setAllowCommands(false);
s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null && e.preset().getRegisteredName().equals("minecraft:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->m.player!=null && ProgressionClient.active() && m.gui.screen()==null && !IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;
clock(p,"2026-08-15T12:00:00Z");
for(int x=-8;x<=10;x++)for(int z=-8;z<=10;z++)p.level().setBlockAndUpdate(new BlockPos(x,81,z),Blocks.GRASS_BLOCK.defaultBlockState());
p.teleportTo(1.5,82,-3.5);p.setYRot(0);p.setXRot(15);p.setGameMode(net.minecraft.world.level.GameType.SPECTATOR);
p.level().setBlockAndUpdate(new BlockPos(0,82,0),Blocks.POPPY.defaultBlockState());
p.level().setBlockAndUpdate(new BlockPos(2,82,0),Blocks.OAK_LOG.defaultBlockState());
p.level().setBlockAndUpdate(new BlockPos(4,81,0),Blocks.WATER.defaultBlockState());
p.level().setBlockAndUpdate(new BlockPos(6,82,0),Blocks.WHITE_TULIP.defaultBlockState());
try { fr.koka.sanctuary.weather.WeatherService.setMode(s,false); } catch(Exception e) { throw new IllegalStateException(e); }
p.level().getWeatherData().setRaining(true);p.level().getWeatherData().setRainTime(6000);p.level().setRainLevel(1);
var waste=new ItemEntity(p.level(),-2.5,82,.5,new ItemStack(Items.ROTTEN_FLESH));waste.setNeverPickUp();p.level().addFreshEntity(waste);
spawn(p,"common_butterfly",new BlockPos(0,81,0));spawn(p,"cockroach",new BlockPos(2,82,0));
spawn(p,"pondskater",new BlockPos(4,81,0));spawn(p,"snail",new BlockPos(6,81,0));
spawn(p,"ant",new BlockPos(-3,81,0));spawn(p,"long_locust",new BlockPos(-1,81,2));
spawn(p,"darner_dragonfly",new BlockPos(4,81,4));
p.level().setBlockAndUpdate(new BlockPos(6,81,4),Blocks.SNOW_BLOCK.defaultBlockState());
spawn(p,"dung_beetle",new BlockPos(6,81,4));
p.level().setBlockAndUpdate(new BlockPos(-4,82,3),Blocks.POPPY.defaultBlockState());
spawn(p,"ladybug",new BlockPos(-4,81,3));
var flower=new BlockPos(-4,82,3);
var ladybug=p.level().getEntitiesOfClass(InsectEntity.class,new net.minecraft.world.phys.AABB(flower).inflate(1),i->i.species().id().equals("ladybug")).getFirst();
check(Math.abs(ladybug.getY()-(flower.getY()+p.level().getBlockState(flower).getShape(p.level(),flower).max(Direction.Axis.Y)+.005))<.001,"Ladybug rests on its native flower outline");
clearWeather(p);
check(!InsectSpawner.supports(p.level(),InsectCatalog.byId("snail"),new BlockPos(6,82,0),Direction.UP),"Normal snail still requires rain");
});
c.waitFor(m->{if(m.level==null)return false;int count=0;for(var e:m.level.entitiesForRendering())if(e instanceof InsectEntity)count++;return count>=9;},300);
// Entity packets can arrive before the native clock/weather sync is applied.
c.waitTicks(20);
c.runOnClient(m->{
check(m.level.getOverworldClockTime()%24000==6000 && m.level.getRainLevel(1)==0 && m.level.isBrightOutside(),"Noon and clear native lighting without night vision: time="+m.level.getOverworldClockTime()+" rain="+m.level.getRainLevel(1)+" bright="+m.level.isBrightOutside());
var buffers=new net.minecraft.client.sounds.SoundBufferLibrary(m.getResourceManager());
for(var call:InsectSounds.all()) {
var event=m.getSoundManager().getSoundEvent(call.event().location());
check(event!=null && event.getWeight()>0,"Registered natural insect audio: "+call.species());
try(var stream=buffers.getStream(fr.koka.sanctuary.SanctuaryMod.id("sounds/insects/"+call.file()+".ogg"),false).join()) {
check(stream.getFormat().getChannels()==1 && stream.read(4096).remaining()>0,"Native OGG mono decoder: "+call.file());
} catch(java.io.IOException e) {throw new IllegalStateException(e);}
}
for(var species:InsectCatalog.ALL) {
check(m.getResourceManager().getResource(fr.koka.sanctuary.SanctuaryMod.id("textures/insect/"+species.id()+".png")).isPresent(),"Missing supplied sprite: "+species.id());
var itemState=new net.minecraft.client.renderer.item.ItemStackRenderState();
m.getItemModelResolver().updateForLiving(itemState,new ItemStack(Insects.specimen(species)),ItemDisplayContext.GUI,m.player);
var material=itemState.pickParticleMaterial(net.minecraft.util.RandomSource.create(0));
check(!itemState.isEmpty() && material!=null && material.sprite().contents().name().equals(fr.koka.sanctuary.SanctuaryMod.id("insect/"+species.id())),"Missing baked inventory sprite: "+species.id());
}
for(var lang:List.of("fr_fr","en_us")) {
m.getLanguageManager().setSelected(lang);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
for(var species:InsectCatalog.ALL)check(!new ItemStack(Insects.specimen(species)).getHoverName().getString().startsWith("item.sanctuary"),"Missing insect translation: "+species.id());
check(!new ItemStack(Insects.NET).getHoverName().getString().startsWith("item.sanctuary"),"Missing net translation");
for(var call:InsectSounds.all())check(!net.minecraft.network.chat.Component.translatable("subtitle.sanctuary.insect_"+call.species()).getString().startsWith("subtitle.sanctuary"),"Translated insect subtitle: "+call.species());
}
var shapes=new HashSet<InsectCatalog.Shape>();
for(var e:m.level.entitiesForRendering())if(e instanceof InsectEntity insect) {
var state=(InsectRenderer.State)m.getEntityRenderDispatcher().extractEntity(insect,1);
check(state.species==insect.species() && state.face==insect.face(),"Synced insect renderer state");shapes.add(state.species.shape());
if(state.species.shape()==InsectCatalog.Shape.WALL)check(state.face.getAxis().isHorizontal(),"Bark insects keep a wall-facing normal");
}
check(shapes.size()==5,"All five rendering paths present, including the custom dragonfly");
});
c.getInput().lookAt(new BlockPos(2,82,0));c.waitTicks(30);
c.runOnClient(m->{boolean snail=false;for(var e:m.level.entitiesForRendering())if(e instanceof InsectEntity i && i.species().id().equals("snail"))snail=true;check(snail && !m.level.isRaining(),"Inspection snail survives clear weather with its real flower support");});
c.takeScreenshot("insects248-day-surfaces");
for(var camera:List.of(new Vec3(-3.5,82,1),new Vec3(-1.5,82,3.5),new Vec3(-3.5,83,6))) {
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(camera.x,camera.y,camera.z));
c.waitTicks(15);c.getInput().lookAt(new BlockPos(-4,82,3));c.waitTicks(3);
c.takeScreenshot("insects249-ladybug-"+camera.x+"-"+camera.z);
}
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(4.5,82,2);p.setYRot(0);p.setXRot(10);});
c.waitTicks(30);c.getInput().lookAt(new BlockPos(4,82,4));c.takeScreenshot("insects248-dragonfly-cross");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(6.5,82,2);p.setYRot(0);p.setXRot(25);});
c.waitTicks(20);c.getInput().lookAt(new BlockPos(6,82,4));c.waitTicks(2);
c.runOnClient(m->{
var beetle=m.level.entitiesForRendering().iterator();boolean rolling=false;
while(beetle.hasNext())if(beetle.next() instanceof InsectEntity i && i.species().id().equals("dung_beetle"))rolling=i.snowRoll()>0;
check(rolling,"The rendered snowball still has its synchronized rolling beetle");
});c.takeScreenshot("insects248-snowball");
// Observe real server movement packets: ordinary travel, then a rapid midair escape.
var hopperId=new java.util.concurrent.atomic.AtomicInteger();
var hopperOrigin=new java.util.concurrent.atomic.AtomicReference<Vec3>();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
p.level().getEntitiesOfClass(InsectEntity.class,p.getBoundingBox().inflate(32),i->i.species().id().equals("long_locust")).forEach(InsectEntity::discard);
p.teleportTo(-.5,82,-.5);spawn(p,"long_locust",new BlockPos(-1,81,2));
var hopper=p.level().getEntitiesOfClass(InsectEntity.class,p.getBoundingBox().inflate(12),i->i.species().id().equals("long_locust")).getFirst();
hopperId.set(hopper.getId());hopperOrigin.set(hopper.position());
});
c.getInput().lookAt(new BlockPos(-1,82,2));
c.waitFor(m->{var e=m.level.getEntity(hopperId.get());return e instanceof InsectEntity i
&& i.getY()>hopperOrigin.get().y+.2 && i.position().subtract(hopperOrigin.get()).horizontalDistanceSqr()>.04;},150);
c.takeScreenshot("insects250-hop-travel");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var hopper=(InsectEntity)p.level().getEntity(hopperId.get());
check(hopper!=null && !hopper.isRemoved(),"Traveling hopper remains available for native escape");
hopperOrigin.set(hopper.position());hopper.scareFrom(hopper.position().add(-2,0,0));
});
c.waitFor(m->{var e=m.level.getEntity(hopperId.get());return e instanceof InsectEntity i && i.fleeing()
&& i.getX()>hopperOrigin.get().x+.2 && i.getY()>82.15;},20);
c.takeScreenshot("insects250-hop-flee");
c.waitFor(m->m.level.getEntity(hopperId.get())==null,80);
// Natural spawning and client removal beneath a newly placed transparent roof.
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(.5,82,5);
var ground=new BlockPos(0,81,8);var species=InsectCatalog.byId("long_locust");
var site=InsectSpawner.findSite(p.level(),species,ground);check(site!=null,"Natural open grass fixture remains valid");
var hopper=InsectSpawner.spawn(p.level(),species,site);check(hopper!=null,"Native natural hopper spawns outside");hopperId.set(hopper.getId());
});
c.waitFor(m->m.level.getEntity(hopperId.get()) instanceof InsectEntity,100);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
for(int x=-1;x<=1;x++)for(int z=7;z<=9;z++)p.level().setBlockAndUpdate(new BlockPos(x,85,z),Blocks.GLASS.defaultBlockState());
check(InsectSpawner.findSite(p.level(),InsectCatalog.byId("long_locust"),new BlockPos(0,81,8))==null,"Glass roof blocks another natural spawn");
});
c.waitFor(m->m.level.getEntity(hopperId.get())==null && m.level.getBlockState(new BlockPos(0,85,8)).is(Blocks.GLASS),80);
c.getInput().lookAt(new BlockPos(0,84,8));c.waitTicks(3);c.takeScreenshot("insects251-house-exclusion");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();for(int x=-1;x<=1;x++)for(int z=7;z<=9;z++)p.level().setBlockAndUpdate(new BlockPos(x,85,z),Blocks.AIR.defaultBlockState());});
// Right-click standing up through the real production packet path.
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();clear(p);p.setGameMode(net.minecraft.world.level.GameType.SURVIVAL);
p.setShiftKeyDown(false);
p.teleportTo(1.5,82,4.5);p.setYRot(-90);p.setXRot(0);p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Insects.NET));
p.level().setBlockAndUpdate(new BlockPos(4,83,4),Blocks.OAK_LOG.defaultBlockState());
var bug=new InsectEntity(Insects.TYPE,p.level());bug.place(InsectCatalog.byId("cockroach"),new BlockPos(4,83,4),Direction.WEST,new Vec3(3.985,83.5,4.5));p.level().addFreshEntity(bug);
});
c.waitFor(m->m.player.getMainHandItem().is(Insects.NET) && Math.abs(m.player.getZ()-4.5)<.1,300);
c.getInput().releaseKey(o->o.keyShift);c.waitTicks(3);c.getInput().lookAt(new BlockPos(4,83,4));c.waitTicks(2);
c.getInput().holdKey(o->o.keyUse);c.waitTicks(2);c.getInput().releaseKey(o->o.keyUse);
server.waitFor(s->s.getPlayerList().getPlayers().getFirst().getInventory().countItem(Insects.specimen(InsectCatalog.byId("cockroach")))==1,100);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(p.getMainHandItem().getDamageValue()==1,"Native sweep wears net once");});
c.runOnClient(m->{try {
var field=net.minecraft.client.gui.Hud.class.getDeclaredField("overlayMessageString");field.setAccessible(true);
var message=(net.minecraft.network.chat.Component)field.get(m.gui.hud);
check(message==null,"Capturing sends no overlay notification");
} catch(ReflectiveOperationException e) {throw new IllegalStateException(e);} });
c.waitFor(m->RecipeClient.knowledge!=null && RecipeClient.knowledge.items.contains("sanctuary:insect_cockroach"),100);
c.runOnClient(m->{
check(NotificationClient.messages().stream().noneMatch(e->e.key().startsWith("item:sanctuary:insect_")),"Captured insect discoveries are silent");
var baseline=new fr.koka.sanctuary.recipes.RecipeKnowledge();NotificationClient.clearFeed();NotificationClient.recipes(baseline,true,100);
var quiet=new fr.koka.sanctuary.recipes.RecipeKnowledge();quiet.items.add("sanctuary:insect_net");quiet.recipes.add("sanctuary:insect_net");
for(var species:InsectCatalog.ALL)quiet.items.add("sanctuary:insect_"+species.id());
NotificationClient.recipes(quiet,true,100);check(NotificationClient.messages().isEmpty(),"All 47 specimens, net and net recipe suppress notices");
var ordinary=fr.koka.sanctuary.recipes.RecipeKnowledge.parse(quiet.json());ordinary.items.add("minecraft:apple");
NotificationClient.recipes(ordinary,true,100);check(NotificationClient.messages().stream().anyMatch(e->e.key().equals("item:minecraft:apple")),"Other item notices retain their behavior");
NotificationClient.recipes(RecipeClient.knowledge,true,100);NotificationClient.clearFeed();
});
c.takeScreenshot("insects245-net-capture");c.getInput().releaseKey(o->o.keyShift);
var heard=new java.util.concurrent.CopyOnWriteArrayList<InsectSoundClient.Ambient>();
net.minecraft.client.sounds.SoundEventListener listener=(sound,event,range)->{if(sound instanceof InsectSoundClient.Ambient a)heard.add(a);};
c.runOnClient(m->m.getSoundManager().addListener(listener));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();clear(p);
clock(p,"2026-08-15T19:00:00Z");spawn(p,"mosquito",new BlockPos(2,81,6));
});
c.waitFor(m->{for(var e:m.level.entitiesForRendering())if(e instanceof InsectEntity i && i.species().id().equals("mosquito"))return true;return false;},100);
c.getInput().lookAt(new BlockPos(2,83,6));
c.waitFor(m->heard.stream().anyMatch(a->a.getIdentifier().equals(InsectSounds.forSpecies(InsectCatalog.byId("mosquito")).event().location()) && m.getSoundManager().isActive(a)),160);
c.takeScreenshot("insects248-mosquito-day");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();for(var i:p.level().getEntitiesOfClass(InsectEntity.class,p.getBoundingBox().inflate(16)))i.scareFrom(p.position());});
c.waitFor(m->heard.stream().allMatch(a->a.isStopped() || !m.getSoundManager().isActive(a)),100);
c.runOnClient(m->m.getSoundManager().removeListener(listener));
System.out.println("INSECTS251_CLIENT_PASS backend=vulkan natural-grass-and-glass-roof-exclusion=pass native-hopping-travel-and-midair-flee=pass textures=47 FR/EN=pass shapes=5 ladybug-three-camera-views=rendered dragonfly-cross=rendered standing-native-net=pass wear=1 silent-discoveries=pass noon-clear=pass dry-inspection-snail=pass audio=9-native-mono-decodes mosquito-moving-sound-and-flee-stop=pass");
} finally {intro.set(false);c.getInput().releaseKey(o->o.keyUse);c.getInput().releaseKey(o->o.keyShift);}
}
private static void clock(ServerPlayer p,String instant) {
try {
var server=p.level().getServer();
var f=RealtimeService.Session.class.getDeclaredField("clock");f.setAccessible(true);f.set(RealtimeService.session(server),Clock.fixed(Instant.parse(instant),ZoneOffset.UTC));
// Deterministic calendar for spawning, explicit native lighting for readable fixture screenshots.
RealtimeService.setMode(server,false);
var definition=server.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.WORLD_CLOCK).getOrThrow(net.minecraft.world.clock.WorldClocks.OVERWORLD);
server.clockManager().getInstance(definition).loadFrom(new net.minecraft.world.clock.ClockState(6000,0,1,true));
server.clockManager().setTotalTicks(definition,6000);
}
catch(Exception e) {throw new IllegalStateException(e);}
}
private static void clearWeather(ServerPlayer p) {
var level=p.level();level.getGameRules().set(net.minecraft.world.level.gamerules.GameRules.ADVANCE_WEATHER,false,level.getServer());
level.getWeatherData().setRaining(false);level.getWeatherData().setThundering(false);level.setRainLevel(0);level.setThunderLevel(0);
p.connection.send(new net.minecraft.network.protocol.game.ClientboundGameEventPacket(net.minecraft.network.protocol.game.ClientboundGameEventPacket.STOP_RAINING,0));
p.connection.send(new net.minecraft.network.protocol.game.ClientboundGameEventPacket(net.minecraft.network.protocol.game.ClientboundGameEventPacket.RAIN_LEVEL_CHANGE,0));
p.connection.send(new net.minecraft.network.protocol.game.ClientboundGameEventPacket(net.minecraft.network.protocol.game.ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE,0));
}
private static void clear(ServerPlayer p) { for(var e:p.level().getEntitiesOfClass(InsectEntity.class,p.getBoundingBox().inflate(32)))e.discard(); }
private static void spawn(ServerPlayer p,String id,BlockPos pos) {
var species=InsectCatalog.byId(id);var site=InsectSpawner.findSite(p.level(),species,pos);
check(site!=null,"Fixture habitat: "+id);
var insect=InsectSpawner.spawn(p.level(),species,site);check(insect!=null,"Fixture spawn: "+id);
// This disposable flat fixture mixes summer and winter species, like the review map.
insect.inspect();
}
}
@@ -0,0 +1,51 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.inventory.SanctuaryInventoryCapacity;
import fr.koka.sanctuary.progression.*;
import java.util.List;
import mezz.jei.api.constants.RecipeTypes;
import mezz.jei.api.sanctuary.CatalogueAccess;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.minecraft.client.gui.screens.inventory.InventoryScreen;
import net.minecraft.world.item.*;
/** Exercise the actual optional fork and its server transfer packet. */
public final class Jei257ClientChecks {
public static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{
var r=SanctuaryJeiPlugin.runtime();
require(r.getIngredientFilter().getFilteredItemStacks().stream().noneMatch(i->i.is(Items.DIAMOND_BLOCK)),"Unknown items remain hidden after permission revocation");
require(r.getRecipeManager().createRecipeLookup(RecipeTypes.CRAFTING).get().anyMatch(v->v.id().identifier().toString().equals("minecraft:crafting_table")),"Learned wood recipe visible");
require(r.getRecipeManager().createRecipeLookup(RecipeTypes.CRAFTING).get().noneMatch(v->v.id().identifier().toString().equals("minecraft:shield")),"Unknown recipe stays hidden");
JeiIntegration.open();
});
c.waitFor(m->m.gui.screen() instanceof mezz.jei.gui.recipes.RecipesGui,120);
c.takeScreenshot("jei257-separate-catalogue");
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var progress=ProgressEffects.get(p);
for(int i=progress.inventory();i<5;i++)progress=progress.buy("inventory",1);
p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);
p.getInventory().clearContent();p.getInventory().setItem(SanctuaryInventoryCapacity.EXTRA_START+17,new ItemStack(Items.OAK_PLANKS,8));
p.inventoryMenu.broadcastChanges();ProgressionService.send(p,"");
});
c.waitFor(m->ProgressionClient.progress.inventoryRows()==6&&m.player.getInventory().getItem(SanctuaryInventoryCapacity.EXTRA_START+17).getCount()==8,120);
c.runOnClient(m->{
m.gui.setScreen(new InventoryScreen(m.player));var menu=m.player.inventoryMenu;
require(CatalogueAccess.inventory.apply(menu,List.of()).size()==54,"Separate JEI sees all six storage rows");
var r=SanctuaryJeiPlugin.runtime();var manager=r.getRecipeManager();var category=manager.getRecipeCategory(RecipeTypes.CRAFTING);
var recipe=manager.createRecipeLookup(RecipeTypes.CRAFTING).get().filter(v->v.id().identifier().toString().equals("minecraft:crafting_table")).findFirst().orElseThrow();
var layout=manager.createRecipeLayoutDrawable(category,recipe,r.getJeiHelpers().getFocusFactory().createFocusGroup(List.of())).orElseThrow();
var handler=r.getRecipeTransferManager().getRecipeTransferHandler(menu,category).orElseThrow();
require(handler.transferRecipe(menu,recipe,layout.getRecipeSlotsView(),m.player,false,true)==null,"Real JEI transfer packet accepts overflow ingredients");
});
c.waitFor(m->m.player.inventoryMenu.getSlot(0).getItem().is(Items.CRAFTING_TABLE),120);
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();
require(p.getInventory().getItem(SanctuaryInventoryCapacity.EXTRA_START+17).getCount()==4,"Exactly four planks consumed from row six");
for(int i=1;i<=4;i++)require(p.inventoryMenu.getSlot(i).getItem().is(Items.OAK_PLANKS)&&p.inventoryMenu.getSlot(i).getItem().getCount()==1,"Server grid contains one plank per slot");
});
System.out.println("JEI257_CLIENT_PASS separate fork, learned/unknown filters, catalogue opening and real six-row transfer");
}
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,121 @@
package fr.koka.sanctuary.client;
import com.google.gson.*;
import com.mojang.authlib.GameProfile;
import fr.koka.sanctuary.atlas.*;
import fr.koka.sanctuary.community.CommunityTask;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.waystone.*;
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.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.PauseScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.renderer.state.gui.*;
import net.minecraft.core.BlockPos;
import net.minecraft.network.protocol.game.*;
import net.minecraft.server.level.*;
import net.minecraft.world.level.block.Blocks;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Native Vulkan checks with real map packets and an online player outside client entity tracking. */
public final class Map243ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
fr.koka.sanctuary.testing.QuickTestWorld.enable();var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get() && m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);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());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Map243-"+UUID.randomUUID());s.setSeed("243");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null && e.preset().getRegisteredName().equals("minecraft:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->m.player!=null && ProgressionClient.active() && m.gui.screen()==null && !IntroSpawnFlash.active(),1200);
var server=world.getServer();
var source=server.computeOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;
var r=ProgressRecord.INITIAL.buy("atlas",1);p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");
var at=p.blockPosition().below();stone(p,at,"Maison");stone(p,at.offset(24,0,0),"Voisin");stone(p,at.offset(12,0,12),"Ascenseur");stone(p,at.offset(12,16,12),"Étage inconnu");
p.teleportTo(p.level(),at.getX()+.5,at.getY()+1,at.getZ()+.5,Set.of(),0,0,true);
for(int dx=-2;dx<=2;dx++)for(int dz=-2;dz<=2;dz++){var tile=AtlasSampler.sample(p.level(),(at.getX()>>4)+dx,(at.getZ()>>4)+dz);if(tile!=null)AtlasService.observations(p).observe(tile);}
return at;
});
var remote=server.computeOnServer(s->{
var host=s.getPlayerList().getPlayers().getFirst();var profile=new GameProfile(UUID.randomUUID(),"Voyageur243");
var p=new ServerPlayer(s,host.level(),profile,ClientInformation.createDefault());
var connection=new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);new io.netty.channel.embedded.EmbeddedChannel(connection);
p.connection=new net.minecraft.server.network.ServerGamePacketListenerImpl(s,connection,p,net.minecraft.server.network.CommonListenerCookie.createInitial(profile,false));
p.setPos(source.getX()+120.5,source.getY()+1,source.getZ()+.5);p.getAbilities().invulnerable=true;
// Online list and PlayerInfo, deliberately without a tracked client entity.
s.getPlayerList().getPlayers().add(p);host.connection.send(ClientboundPlayerInfoUpdatePacket.createPlayerInitializing(List.of(p)));return p.getUUID();
});
try {
c.waitFor(m->ProgressionClient.progress.atlas() && m.getConnection().getPlayerInfo(remote)!=null,200);
var notices=c.computeOnClient(m->{check(m.level.getPlayerByUUID(remote)==null,"Distant fixture has no client entity");
var notice=new JsonObject();notice.addProperty("id",UUID.randomUUID().toString());notice.addProperty("title","Rendez-vous");notice.addProperty("authorName","Voyageur243");notice.addProperty("summary","Même emplacement que le joueur");notice.addProperty("status","open");
notice.add("task",new Gson().toJsonTree(new CommunityTask(new CommunityTask.Location(m.level.dimension().identifier().toString(),source.getX(),source.getY(),source.getZ()),List.of(),"shared","none",List.of(),"")));
var entries=new JsonArray();entries.add(notice);return entries;
});
for(String language:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new SanctuaryAtlasScreen(null));});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen a && a.markers().size()==2,300);
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();a.mouseScrolled(a.width/2.,a.height/2.,0,-8);});
c.waitFor(m->((SanctuaryAtlasScreen)m.gui.screen()).playerPins().stream().anyMatch(p->p.id().equals(remote)),300);
c.runOnClient(m->{CommunityMapMarkers.accept(notices);var a=(SanctuaryAtlasScreen)m.gui.screen();var pins=a.playerPins();
check(pins.size()==2 && pins.getLast().local(),"Two players projected, local face last");
check(pins.getFirst().skin().equals(m.getConnection().getPlayerInfo(remote).getSkin()),"Distant face uses the online player's skin");
check(a.markers().stream().noneMatch(t->t.name().contains("Ascenseur") || t.name().contains("inconnu")),"Unknown elevator floor hides its base in native Atlas packets");
var local=pins.getLast();check(a.questPins().stream().anyMatch(p->p.x()==local.x() && p.y()==local.y()),"Notice intentionally overlaps local face and horizontal waystone");
var state=new GuiRenderState();var graphics=new GuiGraphicsExtractor(m,state,0,0);a.extractBackground(graphics,0,0,1);a.extractRenderState(graphics,0,0,1);faces(m,state,pins);
a.accept(new AtlasPlayersPayload(-1,List.of()));check(a.playerPins().size()==2,"Stale token cannot clear current player markers");
});shot(c,"atlas-"+language+"-"+scale);
c.runOnClient(m->m.gui.setScreen(new PauseScreen(true)));
c.waitFor(m->{var map=((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap();return map!=null && map.waystoneCount()==2 && !map.playerPins().isEmpty();},300);
c.runOnClient(m->{CommunityMapMarkers.accept(notices);var map=((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap();var pins=map.playerPins();
check(pins.getLast().local(),"Pause puts the local face above markers");
var state=new GuiRenderState();map.extractRenderState(new GuiGraphicsExtractor(m,state,0,0),0,0,1);faces(m,state,pins);
map.accept(new AtlasPlayersPayload(-1,List.of()));check(map.playerPins().equals(pins),"Pause rejects stale player snapshots");
});shot(c,"pause-"+language+"-"+scale);
c.runOnClient(m->m.gui.setScreen(null));
}
server.runOnServer(s->{s.getPlayerList().getPlayers().removeIf(p->p.getUUID().equals(remote));});
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.gui.setScreen(new SanctuaryAtlasScreen(null));});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen a && a.markers().size()==2,300);
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();a.mouseScrolled(a.width/2.,a.height/2.,0,-8);check(a.playerPins().size()==1,"Disconnected player absent from next server snapshot");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);ProgressionService.send(p,"");});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen a && a.playerPins().isEmpty(),300);
c.runOnClient(m->{m.gui.setScreen(null);CommunityMapMarkers.clear();});
System.out.println("MAP243_CLIENT_PASS: Vulkan FR/EN GUI 2/3/4, native packets, distant PlayerInfo skin without entity, face and hat UVs, heads above waystone and notice, both maps, unknown elevator hidden, stale tokens, disconnect and revoked Atlas");
}finally{server.runOnServer(s->s.getPlayerList().getPlayers().removeIf(p->p.getUUID().equals(remote)));c.runOnClient(m->{CommunityMapMarkers.clear();m.gui.setScreen(null);});}
}finally{intro.set(false);}
}
private static void stone(ServerPlayer p,BlockPos at,String name) {
p.level().setBlockAndUpdate(at,WaystoneBlock.BLOCK.defaultBlockState());p.level().setBlockAndUpdate(at.above(),Blocks.AIR.defaultBlockState());p.level().setBlockAndUpdate(at.above(2),Blocks.AIR.defaultBlockState());
WaystoneSavedData.get(p.level().getServer()).addOrUpdate(p.level(),at,name);
if(!name.contains("inconnu"))WaystoneDiscoveryData.get(p.level().getServer()).discover(p.getUUID(),p.level(),at);
}
private static void faces(Minecraft m,GuiRenderState state,List<PlayerMapMarkers.Pin> pins) {
var elements=new ArrayList<GuiElementRenderState>();state.forEachElement(elements::add,GuiRenderState.TraverseRange.ALL);
for(var pin:pins) {
var texture=m.getTextureManager().getTexture(pin.skin().body().texturePath()).getTextureView();
var blits=elements.stream().filter(BlitRenderState.class::isInstance).map(BlitRenderState.class::cast)
.filter(b->b.textureSetup().texure0()==texture && b.x0()==pin.x()-6 && b.y0()==pin.y()-6 && b.x1()-b.x0()==12 && b.y1()-b.y0()==12).toList();
check(blits.size()==2 && blits.getFirst().u0()==8/64f && blits.getLast().u0()==40/64f && blits.stream().allMatch(b->b.v0()==8/64f),"Actual GUI extracts face and second skin layer at the projected location");
}
var local=pins.getLast();int firstFace=-1,lastWaystone=-1;
var waystone=m.getTextureManager().getTexture(fr.koka.sanctuary.SanctuaryMod.id("textures/block/waystone.png")).getTextureView();
for(int i=0;i<elements.size();i++)if(elements.get(i) instanceof BlitRenderState b){if(b.textureSetup().texure0()==waystone)lastWaystone=i;if(firstFace<0 && b.x0()==local.x()-6 && b.y0()==local.y()-6 && b.u0()==8/64f)firstFace=i;}
check(firstFace>lastWaystone && lastWaystone>=0,"Player face is submitted after actual Waystone map icons (face="+firstFace+", waystone="+lastWaystone+")");
}
private static void shot(ClientGameTestContext c,String name) {
try{var path=c.takeScreenshot("map243-"+name);var dir=FabricLoader.getInstance().getGameDir().resolve("../../../../../build/map243-screenshots").normalize();Files.createDirectories(dir);Files.copy(path,dir.resolve(name+".png"),StandardCopyOption.REPLACE_EXISTING);}catch(Exception e){throw new AssertionError(e);}
}
}
@@ -0,0 +1,124 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.atlas.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.progression.*;
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.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.PauseScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.material.MapColor;
import net.minecraft.world.level.storage.LevelResource;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Actual vanilla chunk delivery, creative flight, live packets and paused stored surveys on Vulkan. */
public final class Map244ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
fr.koka.sanctuary.testing.QuickTestWorld.enable();var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get() && m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);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());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Map244-"+UUID.randomUUID());s.setSeed("244");s.setAllowCommands(false);
s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null
&& e.preset().getRegisteredName().equals("minecraft:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->m.player!=null && ProgressionClient.active() && m.gui.screen()==null && !IntroSpawnFlash.active(),1200);
var server=world.getServer();
var start=server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
p.getAbilities().invulnerable=true;p.getAbilities().flying=true;p.onUpdateAbilities();
var r=ProgressRecord.INITIAL.buy("atlas",1);p.setAttached(ProgressionService.RECORD,r.json());
ProgressEffects.apply(p,r);ProgressionService.send(p,"");return p.blockPosition();
});
c.waitTicks(60);
var initial=received(c);server.runOnServer(s->coverage(s.getPlayerList().getPlayers().getFirst(),initial));
check(initial.size()>25,"Personal atlas covers the actual view beyond the old 5x5 survey");
c.waitFor(m->ProgressionClient.progress.atlas(),200);
c.runOnClient(m->m.gui.setScreen(new SanctuaryAtlasScreen(null)));
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen a && a.observedTiles()>=initial.size(),300);
int token=c.computeOnClient(m->field(m.gui.screen(),"token",Integer.class));
// 320 blocks in 80 ticks, with the map open: old sampling skipped whole chunks.
for(int step=1;step<=20;step++) {
int x=start.getX()+step*16;
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
p.teleportTo(p.level(),x+.5,start.getY()+20,start.getZ()+.5,Set.of(),0,0,true);
});c.waitTicks(4);
}
c.waitTicks(30);
var flight=received(c);server.runOnServer(s->coverage(s.getPlayerList().getPlayers().getFirst(),flight));
c.waitFor(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();return colors(a).keySet().containsAll(flight);},200);
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();
check(field(a,"token",Integer.class)==token,"New flight chunks arrive without waiting for another request");
for(var key:flight)for(byte color:colors(a).get(key))check(color!=0,"No fog holes in delivered chunks");
a.accept(new AtlasPayloads.Response(-1,false,true,false,false,"ready",List.of(new AtlasStore.Tile(0,0,new byte[256]))));
check(field(a,"token",Integer.class)==token,"Stale delta cannot change the live token");
});
var gold=new BlockPos(start.getX()+320,100,start.getZ());
server.runOnServer(s->s.overworld().setBlockAndUpdate(gold,Blocks.GOLD_BLOCK.defaultBlockState()));
int pixel=Math.floorMod(gold.getZ(),16)*16+Math.floorMod(gold.getX(),16);
var key=new AtlasStore.Key(gold.getX()>>4,gold.getZ()>>4);
c.waitFor(m->{var colors=colors((SanctuaryAtlasScreen)m.gui.screen()).get(key);
return colors!=null && Byte.toUnsignedInt(colors[pixel])/4==MapColor.GOLD.id;},60);
c.runOnClient(m->check(field(m.gui.screen(),"token",Integer.class)==token,"Visible construction refresh uses a delta"));
for(String lang:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.getLanguageManager().setSelected(lang);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
m.options.guiScale().set(scale);m.resizeGui();m.gui.setScreen(new PauseScreen(true));});
c.waitFor(m->{var map=((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap();return map!=null && map.knownPixels()>=flight.size()*256;},300);
c.runOnClient(m->m.gui.setScreen(null));
}
// Change the real client distance; receive and observe vanilla's larger chunk footprint.
c.runOnClient(m->{m.options.renderDistance().set(32);m.options.broadcastOptions();});
c.waitFor(m->m.level.getChunkSource().getLoadedChunksCount()>3000,1200);
var wideKeys=received(c);
int wide=server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var keys=wideKeys;coverage(p,keys);
check(keys.size()>3000,"32-chunk client view has been delivered");
var chunks=fr.koka.sanctuary.knowledge.census.LoadedChunks.full(p.level()).stream()
.filter(ch->p.level().getChunkSource().chunkMap.isChunkTracked(p,ch.getPos().x(),ch.getPos().z())).limit(128).toList();
for(var chunk:chunks.subList(0,Math.min(16,chunks.size())))AtlasSampler.sampleLayers(chunk);
long[] timings=new long[chunks.size()];
for(int i=0;i<chunks.size();i++){long t=System.nanoTime();AtlasSampler.sampleLayers(chunks.get(i));timings[i]=System.nanoTime()-t;}
Arrays.sort(timings);System.out.printf(Locale.ROOT,"MAP244_SAMPLE_CPU: %d flat chunks, mean %.3f ms, p95 %.3f ms, six layers%n",
timings.length,Arrays.stream(timings).average().orElseThrow()/1e6,timings[(timings.length-1)*95/100]/1e6);
try{var store=AtlasService.observations(p);store.save();var path=s.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-atlas/"+p.getUUID()+".json");
check(new AtlasStore(path,p.getUUID(),s.overworld().getSeed()).size()==store.size(),"Existing atlas schema reloads all flight discoveries");
}catch(Exception e){throw new RuntimeException(e);}return keys.size();
});
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.broadcastOptions();m.gui.setScreen(new SanctuaryAtlasScreen(null));});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen a && a.observedTiles()>1000,300);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());
ProgressEffects.apply(p,ProgressRecord.INITIAL);ProgressionService.send(p,"");});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen a && a.observedTiles()==0,200);
c.runOnClient(m->m.gui.setScreen(null));
System.out.println("MAP244_CLIENT_PASS: Vulkan, creative 320-block flight, "+wide+" delivered chunks at view 32, six complete personal layers, live terrain and construction deltas, stable token, Pause FR/EN GUI 2/3/4, save reload, stale packet and revoked Atlas");
}finally{intro.set(false);c.runOnClient(m->{m.options.renderDistance().set(5);m.gui.setScreen(null);});}
}
private static Set<AtlasStore.Key> received(ClientGameTestContext c) {
return c.computeOnClient(m->{var keys=new HashSet<AtlasStore.Key>();var center=m.player.chunkPosition();
for(int z=center.z()-35;z<=center.z()+35;z++)for(int x=center.x()-35;x<=center.x()+35;x++)
if(m.level.getChunkSource().getChunk(x,z,net.minecraft.world.level.chunk.status.ChunkStatus.FULL,false)!=null)
keys.add(new AtlasStore.Key(x,z));
return keys;
});
}
private static void coverage(ServerPlayer p,Set<AtlasStore.Key> keys) {
for(var key:keys)for(int altitude:AtlasAltitude.LEVELS)
check(AtlasService.observations(p,altitude).contains(key.x(),key.z()),
"Client-received chunk "+key+" is observed at altitude "+altitude);
}
@SuppressWarnings("unchecked") private static Map<AtlasStore.Key,byte[]> colors(SanctuaryAtlasScreen a) {return field(a,"surveyed",Map.class);}
private static <T> T field(Object target,String name,Class<T> type) {
try{var f=target.getClass().getDeclaredField(name);f.setAccessible(true);return type.cast(f.get(target));}
catch(Exception e){throw new RuntimeException(e);}
}
}
@@ -10,6 +10,7 @@ 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.loader.api.FabricLoader;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
@@ -37,6 +38,7 @@ public final class Story234ClientChecks implements FabricClientGameTest {
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var r=ProgressRecord.INITIAL;if(unlocked)r=r.buy("atlas",r.cost("atlas",ProgressionService.session(s).config));p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");});
c.waitFor(m->ProgressionClient.progress.atlas()==unlocked,200);
String before=server.computeOnServer(s->s.getPlayerList().getPlayers().getFirst().getAttached(ProgressionService.RECORD));
shortcut(c,unlocked);
for(String language:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new PauseScreen(true));});
c.waitTicks(3);
@@ -44,6 +46,10 @@ public final class Story234ClientChecks implements FabricClientGameTest {
shot(c,"pause-"+unlocked+"-"+language+"-"+scale);
c.runOnClient(m->{var pause=m.gui.screen();press(pause,"sanctuary.story.title");check(m.gui.screen() instanceof StoryScreen,"Story opens from Pause without Atlas gating");check(m.gui.screen().isPauseScreen(),"Story retains solo pause");check(m.gui.screen().getTitle().getString().equals("Story"),"Story has its own accessible screen title");check(edit(m.gui.screen()).getValue().isEmpty()&&cycle(m.gui.screen()).getValue().toString().equals("ALL"),"Empty search and All on opening");assertEntries(m.gui.screen(),100);layout(m.gui.screen());});
shot(c,"story-"+language+"-"+scale);
c.runOnClient(m->{var s=m.gui.screen();var search=edit(s);s.setFocused(search);
s.keyPressed(new KeyEvent(mapKey().getDefaultKey().getValue(),InputConstants.KEYCODE_M,0));
check(s.charTyped(new CharacterEvent('m'))&&m.gui.screen()==s&&search.getValue().equals("m")&&search.isFocused(),"Typing M in Story keeps its screen, text and focus");
check(net.minecraft.network.chat.Component.translatable("sanctuary.atlas.key").getString().equals(language.equals("fr_fr")?"Ouvrir la carte":"Open world map"),"Map shortcut has a localized Controls label");search.setValue("");});
c.runOnClient(m->{var s=m.gui.screen();var search=edit(s);s.setFocused(search);for(int codepoint:"100".codePoints().toArray()){check(s.charTyped(new CharacterEvent(codepoint)),"Native typing handled");check(s.getFocused()==search&&search.isFocused(),"List refresh retains search focus");}check(search.getValue().equals("100")&&search.getCursorPosition()==3,"Continuous typing retains cursor");assertEntries(s,1);check(entries(s).getFirst().getMessage().getString().equals(StoryScreen.label("unknown_entry",100).getString()),"Search selects story 100");search.setValue(" 100 ");assertEntries(s,1);cycle(s).onPress(enter());check(has(s,"sanctuary.story.no_discoveries")&&!has(s,"sanctuary.story.no_results")&&entries(s).isEmpty(),"Discovered has a distinct empty state");cycle(s).onPress(enter());check(cycle(s).getValue().toString().equals("UNKNOWN"),"Third filter is Unknown");assertEntries(s,1);search.setValue("absent-story");check(has(s,"sanctuary.story.no_results")&&!has(s,"sanctuary.story.no_discoveries"),"Search empty state is distinct");search.setValue(language.equals("fr_fr")?"INCONNUE":"UNKNOWN");assertEntries(s,100);var area=area(s);check(s.mouseScrolled(area.getX()+5,area.getY()+5,0,-5)&&area.scrollAmount()>0,"Native wheel scrolls Story");area.setScrollAmount(area.maxScrollAmount());check(entries(s).getLast().getBottom()<=area.getBottom(),"Story 100 can be reached at the end");area.setScrollAmount(100);});
c.runOnClient(m->{m.getWindow().setWindowed(1344,1008);m.resizeGui();});c.waitTicks(3);
c.runOnClient(m->{var s=m.gui.screen();check(edit(s).getValue().equals(language.equals("fr_fr")?"INCONNUE":"UNKNOWN")&&cycle(s).getValue().toString().equals("UNKNOWN"),"Resize retains query and filter");check(area(s).scrollAmount()==100,"Resize retains scroll position");layout(s);edit(s).setValue("100");check(area(s).scrollAmount()==0,"Search resets scroll");edit(s).setValue("");area(s).setScrollAmount(100);cycle(s).onPress(enter());check(cycle(s).getValue().toString().equals("ALL")&&area(s).scrollAmount()==0,"Filter resets scroll and cycles back to All");assertEntries(s,100);});
@@ -62,6 +68,35 @@ public final class Story234ClientChecks implements FabricClientGameTest {
}finally{intro.set(false);}
}
private static KeyMapping mapKey(){return KeyMapping.get("sanctuary.atlas.key");}
private static void shortcut(ClientGameTestContext c,boolean unlocked) {
var saved=c.computeOnClient(m->InputConstants.getKey(mapKey().saveString()));
int scan=c.computeOnClient(m->{var key=mapKey();check(key!=null,"Map is registered in native Controls");
int nativeScan=org.lwjgl.sdl.SDLKeyboard.SDL_GetScancodeFromKey(InputConstants.KEYCODE_M,(java.nio.ShortBuffer)null);
check(nativeScan>0&&key.getDefaultKey().getValue()==nativeScan,"Default follows the keyboard layout's M letter");
key.setKey(key.getDefaultKey());KeyMapping.resetMapping();m.gui.setScreen(null);return nativeScan;});
try {
c.getInput().pressKey(scan);c.waitTicks(5);
c.runOnClient(m->{check(unlocked?m.gui.screen() instanceof SanctuaryAtlasScreen:m.gui.screen()==null,"Game M respects Atlas unlock");});
if(unlocked){c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen()==null,"Atlas opened from game returns to game"));}
var pause=c.computeOnClient(m->{var screen=new PauseScreen(true);m.gui.setScreen(screen);return screen;});
c.getInput().pressKey(scan);c.waitTicks(3);
c.runOnClient(m->{check(unlocked?m.gui.screen() instanceof SanctuaryAtlasScreen:m.gui.screen()==pause,"Pause M respects Atlas unlock");
if(unlocked){press(m.gui.screen(),"gui.done");check(m.gui.screen()==pause,"M from Pause keeps original parent");}m.gui.setScreen(null);});
if(unlocked) {
c.runOnClient(m->{mapKey().setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_J));KeyMapping.resetMapping();});
c.getInput().pressKey(scan);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen()==null,"Old M binding stops opening the map"));
c.getInput().pressKey(InputConstants.KEY_J);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen() instanceof SanctuaryAtlasScreen,"Remapped J opens map from game"));
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
c.runOnClient(m->m.gui.setScreen(pause));c.getInput().pressKey(scan);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen()==pause,"Pause respects removed M binding"));
c.getInput().pressKey(InputConstants.KEY_J);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen() instanceof SanctuaryAtlasScreen,"Pause respects remapped J"));
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);c.runOnClient(m->{check(m.gui.screen()==pause,"Remapped shortcut retains Pause parent");mapKey().setKey(InputConstants.UNKNOWN);KeyMapping.resetMapping();});
c.getInput().pressKey(scan);c.getInput().pressKey(InputConstants.KEY_J);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen()==pause,"Unbound disables map shortcut"));
}
System.out.println("ATLAS236_SHORTCUT_PASS: native layout-aware M, game/Pause returns, unlock="+unlocked+", configurable key and Unbound, binding restored");
}finally{c.getInput().releaseKey(scan);c.getInput().releaseKey(InputConstants.KEY_J);c.runOnClient(m->{m.gui.setScreen(null);mapKey().setKey(saved);KeyMapping.resetMapping();});}
}
private static List<Button> entries(Screen s){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals("sanctuary.story.unknown_entry")).toList();}
private static String key(AbstractWidget w){return w.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
private static CycleButton<?> cycle(Screen s){return widgets(s).filter(CycleButton.class::isInstance).map(CycleButton.class::cast).findFirst().orElseThrow();}
@@ -0,0 +1,133 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.atlas.*;
import fr.koka.sanctuary.familiar.FamiliarExperience;
import fr.koka.sanctuary.gametest.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.waystone.*;
import fr.koka.sanctuarytest.SharedIsland196;
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.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerPlayer;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Actual Large world, real input, server payloads and uploaded map textures under Vulkan. */
public final class Waystone239ClientChecks implements FabricClientGameTest {
@Override 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 s){
IntroClient.setQuickTestIntro(true);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());press(s,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{
s.setName("Waystones239-Large-"+UUID.randomUUID());s.setSeed("0");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
var preset=s.getSettings().worldgenLoadContext().lookupOrThrow(Registries.WORLD_PRESET).getOrThrow(ResourceKey.create(Registries.WORLD_PRESET,SharedIsland196.Size.LARGE.id()));
s.setWorldType(new WorldCreationUiState.WorldTypeEntry(preset));
}).create()){
c.waitFor(m->m.player!=null && ProgressionClient.active() && m.gui.screen()==null && !IntroSpawnFlash.active(),2400);
var server=world.getServer();
var scene=server.computeOnServer(s->{var p=p(s);p.getAbilities().invulnerable=true;return new WaystoneLife239(p.level());});
var source=scene.houses.get("Centre");var elevator=scene.houses.get("Ascenseur");var first=scene.houses.get("Centre 1");var second=scene.houses.get("Centre 2");
server.runOnServer(s->{var p=p(s);scene.prepare(p);var r=ProgressRecord.INITIAL.buy("atlas",ProgressRecord.INITIAL.cost("atlas",ProgressionService.session(s).config));p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");
// Read actual generated terrain in already loaded house chunks for this fixture's reconnaissance.
var survey=AtlasService.observations(p);
for(var at:scene.houses.values())for(int dx=-1;dx<=1;dx++)for(int dz=-1;dz<=1;dz++){var tile=AtlasSampler.sample(p.level(),(at.getX()>>4)+dx,(at.getZ()>>4)+dz);if(tile!=null)survey.observe(tile);}move(p,elevator);
});
c.waitFor(m->ProgressionClient.progress.atlas() && Math.abs(m.player.getY()-elevator.getY()-1)<.1,300);c.waitTicks(12);
c.getInput().holdKey(o->o.keyJump);c.waitTicks(25);c.getInput().releaseKey(o->o.keyJump);c.waitTicks(8);
server.runOnServer(s->{check(p(s).blockPosition().equals(first.above()),"Native held jump climbs exactly one unknown floor");check(FamiliarExperience.playerXp(p(s))==159,"Native jump costs one point");});
c.getInput().holdKey(o->o.keyJump);c.waitTicks(25);c.getInput().releaseKey(o->o.keyJump);c.waitTicks(8);
server.runOnServer(s->check(p(s).blockPosition().equals(second.above()) && FamiliarExperience.playerXp(p(s))==158,"Second jump climbs next floor once"));
c.getInput().holdKey(o->o.keyShift);c.waitTicks(25);c.getInput().releaseKey(o->o.keyShift);c.waitTicks(8);
server.runOnServer(s->check(p(s).blockPosition().equals(first.above()) && FamiliarExperience.playerXp(p(s))==157,"Held native crouch descends once"));
server.runOnServer(s->{move(p(s),elevator);check(!Waystones.teleport(p(s),elevator,source),"Elevator cannot depart to the horizontal house network");Waystones.open(p(s),elevator);});
c.waitFor(m->m.gui.screen() instanceof WaystoneScreen,200);
for(boolean vertical:List.of(true,false)){
if(!vertical){server.runOnServer(s->{move(p(s),source);Waystones.open(p(s),source);});c.waitFor(m->m.gui.screen() instanceof WaystoneScreen w && !w.data().elevator(),200);}
for(String lang:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)){
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(lang);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());});c.getInput().setCursorPos(0,0);c.waitTicks(3);
c.runOnClient(m->{var screen=(WaystoneScreen)m.gui.screen();check(screen.data().elevator()==vertical && screen.data().destinations().size()==(vertical?2:7),"Actual server separates two elevator floors from seven horizontal houses");
check(screen.data().tariff().equals(WaystoneTariff.DEFAULT),"Menu receives the server tariff");
if(!vertical)check(screen.data().destinations().stream().filter(d->d.name().equals("Alice")).findFirst().orElseThrow().cost()==4,"Default house price is four points");
for(var child:screen.children())if(child instanceof AbstractWidget w)check(w.getX()>=0 && w.getRight()<=screen.width && w.getY()>=0 && w.getBottom()<=screen.height,"Outer widget fits viewport");
for(var button:screen.destinationButtons())check(Math.abs(button.getX()+button.getWidth()/2.-screen.width/2.)<=1 && !button.getMessage().getString().contains("Y "),"Destination button centered without altitude");
check(!has(screen,"sanctuary.waystone.rename"),"No Rename button");
check(widgets(screen).filter(StringWidget.class::isInstance).map(StringWidget.class::cast).noneMatch(w->w.getMessage().getContents() instanceof TranslatableContents t && Set.of("sanctuary.waystone.distance","sanctuary.waystone.xp","sanctuary.waystone.rate","sanctuary.waystone.price").contains(t.getKey())),"Distance and XP have no permanent text widgets");
if(vertical){var hint=Waystones.label("elevator_hint").getString();check(hint.contains("jump") && hint.contains("sneak") && !hint.contains("Shift"),"Action labels use jump and sneak");}
});shot(c,(vertical?"elevator-":"horizontal-")+lang+"-"+scale);
c.runOnClient(m->{var screen=(WaystoneScreen)m.gui.screen();var box=edit(screen);screen.setFocused(box);box.setValue(vertical?"Centre 2":"Alice");
check(screen.destinationButtons().size()==1,"Search filters the appropriate network by name");
box.moveCursorTo(2,false);m.resizeGui();box=edit(screen);check(box.isFocused() && box.getCursorPosition()==2 && screen.destinationButtons().size()==1,"Resize retains focused search, cursor and filter");box.moveCursorToEnd(false);
screen.keyPressed(new KeyEvent(InputConstants.KEY_R,InputConstants.KEYCODE_R,0));screen.charTyped(new CharacterEvent('r'));
check(!screen.renaming() && screen.destinationButtons().isEmpty(),"R typed in search is a literal letter");box.setValue(vertical?"Centre 2":"Alice");
screen.keyPressed(enter());screen.keyPressed(new KeyEvent(InputConstants.KEY_R,InputConstants.KEYCODE_R,0));check(screen.renaming(),"R outside text editing opens rename");
edit(screen).setValue("Cancelled");screen.keyPressed(new KeyEvent(InputConstants.KEY_ESCAPE,0,0));check(!screen.renaming() && screen.destinationButtons().size()==1,"Escape cancels rename and restores search");
edit(screen).setValue("");screen.setFocused(null);edit(screen).setFocused(false);screen.keyPressed(new KeyEvent(InputConstants.KEY_R,InputConstants.KEYCODE_R,0));
edit(screen).setValue("Maison M "+scale);check(screen.charTyped(new CharacterEvent('m')) && m.gui.screen()==screen,"M inside rename remains text");
edit(screen).setValue((vertical?"Ascenseur ":"Maison ")+scale);screen.keyPressed(enter());
});
String renamed=(vertical?"Ascenseur ":"Maison ")+scale;
c.waitFor(m->m.gui.screen() instanceof WaystoneScreen w && !w.renaming() && w.data().name().equals(renamed),200);
}
}
c.runOnClient(m->{var screen=(WaystoneScreen)m.gui.screen();screen.setFocused(null);edit(screen).setFocused(false);screen.keyPressed(new KeyEvent(InputConstants.KEY_R,InputConstants.KEYCODE_R,0));edit(screen).setValue("Maison centrale");screen.keyPressed(enter());});
c.waitFor(m->m.gui.screen() instanceof WaystoneScreen w && !w.renaming() && w.data().name().equals("Maison centrale"),200);
server.runOnServer(s->{check(!s.getWorldData().isAllowCommands() && WaystoneSettings.canChange(p(s).createCommandSourceStack()),"Solo owner can configure a tariff without enabling cheats");command(p(s),"sanctuary waystone cost 2 16");});
c.waitFor(m->m.gui.screen() instanceof WaystoneScreen w && w.data().tariff().equals(new WaystoneTariff(2,16)) && w.data().destinations().stream().anyMatch(d->d.name().equals("Alice") && d.cost()==7),200);
c.runOnClient(m->m.gui.screen().setFocused(null));
var hover=c.computeOnClient(m->{var screen=(WaystoneScreen)m.gui.screen();var button=screen.destinationButtons().stream().filter(b->b.getMessage().getString().equals("Alice")).findFirst().orElseThrow();return new double[]{(button.getX()+button.getWidth()/2.)*m.getWindow().getScreenWidth()/screen.width,(button.getY()+button.getHeight()/2.)*m.getWindow().getScreenHeight()/screen.height};});
c.getInput().setCursorPos(hover[0],hover[1]);c.waitTicks(15);shot(c,"configured-rate-hover");c.getInput().setCursorPos(0,0);c.waitTicks(3);shot(c,"configured-rate");c.runOnClient(m->travel(m.gui.screen(),"Alice"));
var alice=scene.houses.get("Alice");
c.waitFor(m->m.gui.screen()==null && m.player.blockPosition().equals(alice.above()),300);
server.runOnServer(s->{check(FamiliarExperience.playerXp(p(s))==150,"Configured house visit rounds the distance tariff once to seven points");Waystones.open(p(s),alice);});
c.waitFor(m->m.gui.screen() instanceof WaystoneScreen,200);c.waitTicks(12);
c.runOnClient(m->travel(m.gui.screen(),"Maison centrale"));
c.waitFor(m->m.gui.screen()==null && m.player.blockPosition().equals(source.above()),300);
server.runOnServer(s->{check(FamiliarExperience.playerXp(p(s))==143,"Configured round trip debits fourteen points");Waystones.open(p(s),source);command(p(s),"sanctuary waystone cost 1 16");});
c.waitFor(m->m.gui.screen() instanceof WaystoneScreen w && w.data().tariff().equals(WaystoneTariff.DEFAULT),200);
c.runOnClient(m->m.gui.screen().onClose());c.waitTicks(5);
var binding=c.computeOnClient(m->InputConstants.getKey(KeyMapping.get("sanctuary.atlas.key").saveString()));
int scan=c.computeOnClient(m->{var key=KeyMapping.get("sanctuary.atlas.key");key.setKey(key.getDefaultKey());KeyMapping.resetMapping();return key.getDefaultKey().getValue();});
try{
server.runOnServer(s->System.out.println("WAYSTONES239_MAP_SERVER: "+Waystones.markers(p(s),new AtlasPayloads.Request(1,source.getX()>>4,source.getZ()>>4,false)).size()+" markers, readable="+AtlasService.canRead(p(s),false)));
c.getInput().pressKey(scan);c.waitTicks(10);
c.runOnClient(m->System.out.println("WAYSTONES239_MAP_CLIENT: screen="+m.gui.screen()+", atlas="+ProgressionClient.progress.atlas()+", enabled="+AptitudeClient.enabled("atlas")+(m.gui.screen() instanceof SanctuaryAtlasScreen a ? ", status="+a.status()+", markers="+a.markers().size():"")));
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen a && a.markers().stream().filter(t->t.kind().equals("waystone")).count()==8,400);
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();check(a.markers().stream().anyMatch(t->t.name().equals("Maison centrale")),"Map opened by M receives renamed real Waystone");
check(a.markers().stream().noneMatch(t->t.x()==elevator.getX() && t.z()==elevator.getZ()),"All three elevator floors are absent from the Atlas");
m.options.guiScale().set(2);m.resizeGui();a.mouseScrolled(a.width/2.,a.height/2.,0,-5);a.keyPressed(new KeyEvent(InputConstants.KEY_DOWN,0,0));
});c.waitTicks(10);shot(c,"large-map-m");
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(4);
var pause=c.computeOnClient(m->{var s=new PauseScreen(true);m.gui.setScreen(s);return s;});
c.waitFor(m->((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap()!=null && ((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap().waystoneCount()==8,400);shot(c,"large-pause-markers");
c.getInput().pressKey(scan);c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen a && !a.markers().isEmpty(),400);
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(4);c.runOnClient(m->check(m.gui.screen()==pause,"M from Pause returns to the same Pause"));
}finally{c.runOnClient(m->{KeyMapping.get("sanctuary.atlas.key").setKey(binding);KeyMapping.resetMapping();m.gui.setScreen(null);});}
c.runOnClient(m->{var unnamed=source.getX()+", "+source.getY()+", "+source.getZ();var at=source.offset(1,7,1);var menu=new WaystonePayloads.Menu(source,unnamed,false,WaystoneTariff.DEFAULT,List.of(new WaystonePayloads.Destination(at,at.getX()+", "+at.getY()+", "+at.getZ(),8,Integer.MAX_VALUE,false)));
var screen=new WaystoneScreen(menu);m.gui.setScreen(screen);check(screen.destinationButtons().getFirst().getMessage().getString().equals(Waystones.label("at",at.getX(),at.getZ()).getString()),"Default coordinate names hide altitude too");
screen.keyPressed(new KeyEvent(InputConstants.KEY_R,InputConstants.KEYCODE_R,0));check(edit(screen).getValue().equals(Waystones.label("at",source.getX(),source.getZ()).getString()),"R editor starts from the name without altitude");m.gui.setScreen(null);
});
server.runOnServer(s->scene.release(p(s).level()));
System.out.println("WAYSTONES241_CLIENT_PASS: actual Sanctuary Large seed 0, separate elevator and house networks, native jump/sneak, search and literal R/M editing, rename via R and Enter, centered buttons without Y, distance and price only on hover, FR/EN GUI 2/3/4, solo owner cost command without cheats, live tariff refresh, actual configured XP debit, M and markers in both maps");
}finally{intro.set(false);}
}
private static ServerPlayer p(net.minecraft.server.MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static void move(ServerPlayer p,net.minecraft.core.BlockPos at){p.teleportTo(p.level(),at.getX()+.5,at.getY()+1,at.getZ()+.5,Set.of(),p.getYRot(),p.getXRot(),true);p.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO);p.resetFallDistance();}
private static void command(ServerPlayer p,String command){try{check(p.level().getServer().getCommands().getDispatcher().execute(command,p.createCommandSourceStack())==1,"Native cost command succeeds");}catch(Exception e){throw new AssertionError(e);}}
private static void travel(Screen screen,String name){((WaystoneScreen)screen).destinationButtons().stream().filter(b->b.getMessage().getString().equals(name)).findFirst().orElseThrow().onPress(enter());}
private static void shot(ClientGameTestContext c,String name){try{String version=FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow().getMetadata().getVersion().getFriendlyString();String prefix="waystones"+version.substring("beta.".length());var path=c.takeScreenshot(prefix+"-"+name);var dir=FabricLoader.getInstance().getGameDir().resolve("../../../../../build/"+prefix+"-screenshots").normalize();Files.createDirectories(dir);Files.copy(path,dir.resolve(name+".png"),StandardCopyOption.REPLACE_EXISTING);}catch(Exception e){throw new AssertionError(e);}}
}
@@ -0,0 +1,105 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.zipline.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.zipline.*;
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.renderer.block.dispatch.BlockStateModelPart;
import net.minecraft.client.renderer.entity.state.ThrownItemRenderState;
import net.minecraft.core.*;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.Vec3;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Native input, synchronized cable render states, travel and a real fall in a new flat Vulkan world. */
public final class Zipline242ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
fr.koka.sanctuary.testing.QuickTestWorld.enable();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) {
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());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Ziplines242-"+UUID.randomUUID());s.setSeed("242");
s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null && e.preset().getRegisteredName().equals("minecraft:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->m.player!=null && ProgressionClient.active() && m.gui.screen()==null && !IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.getAbilities().invulnerable=true;
for(int x=1;x<=3;x++)for(int z=-1;z<=1;z++)p.level().setBlockAndUpdate(new BlockPos(x,81,z),Blocks.STONE.defaultBlockState());
for(int y=82;y<=99;y++)for(int z=-1;z<=1;z++)p.level().setBlockAndUpdate(new BlockPos(42,y,z),Blocks.STONE.defaultBlockState());
var a=new BlockPos(3,84,3);var b=new BlockPos(20,84,3);
p.level().setBlockAndUpdate(a,Ziplines.PULLEY_HEAD.defaultBlockState());p.level().setBlockAndUpdate(b,Ziplines.PULLEY_HEAD.defaultBlockState());
var lead=new ItemStack(Items.LEAD);PulleyHeadController.handleLeadUse(p.level(),p,InteractionHand.MAIN_HAND,lead,a);PulleyHeadController.handleLeadUse(p.level(),p,InteractionHand.MAIN_HAND,lead,b);
prepare(p);
});
c.waitFor(m->m.player.getY()>80 && m.player.getMainHandItem().is(Ziplines.GRAPPLING_ROD) && m.level.getBlockEntity(new BlockPos(3,84,3))!=null,300);
c.runOnClient(m->{
for(var state:Ziplines.PULLEY_HEAD.getStateDefinition().getPossibleStates()) {
var parts=new ArrayList<BlockStateModelPart>();m.getModelManager().getBlockStateModelSet().get(state).collectParts(net.minecraft.util.RandomSource.create(242),parts);
int faces=0;var directions=new ArrayList<>(Arrays.asList(Direction.values()));directions.add(null);
for(var d:directions)for(var part:parts)for(var quad:part.getQuads(d)) {check(!quad.materialInfo().sprite().contents().name().toString().contains("missing"),"Missing pulley texture");faces++;}
check(faces>0,"Pulley orientation has no geometry: "+state);
}
var be=(PulleyHeadBlockEntity)m.level.getBlockEntity(new BlockPos(3,84,3));
var renderer=(PulleyHeadRenderer)(Object)m.getBlockEntityRenderDispatcher().getRenderer(be);
var state=renderer.createRenderState();renderer.extractRenderState(be,state,1,m.player.position(),null);
check(state.leashStates!=null && state.leashStates.size()==3 && state.leashStates.stream().noneMatch(l->l.slack),"Fixed cable renders straight in segments");
for(var lang:List.of("fr_fr","en_us")) {m.getLanguageManager().setSelected(lang);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
check(!new ItemStack(Ziplines.GRAPPLING_ROD).getHoverName().getString().contains("item.sanctuary"),"Translated grappling item");
check(!net.minecraft.network.chat.Component.translatable("tooltip.sanctuary.grapple").getString().equals("tooltip.sanctuary.grapple"),"Translated controls");}
});
launch(c);c.waitFor(m->anchored(m)!=null,300);
c.runOnClient(m->{var hook=anchored(m);var state=(ThrownItemRenderState)m.getEntityRenderDispatcher().extractEntity(hook,1);
check(state.leashStates!=null && !state.leashStates.isEmpty() && state.leashStates.stream().noneMatch(l->l.slack),"Synced portable cable uses straight native leash renderer");
check(new GrapplingRodCastProperty().get(m.player.getMainHandItem(),m.level,m.player,0,ItemDisplayContext.FIRST_PERSON_RIGHT_HAND),"Thrown item switches to historical cast model");
});
c.takeScreenshot("ziplines242-portable-cable");
server.runOnServer(s->check(player(s).getInventory().getItem(1).getCount()==3,"Native charged use consumes exactly one lead"));
c.getInput().holdKey(o->o.keyShift);c.waitTicks(3);c.getInput().releaseKey(o->o.keyShift);
server.waitFor(s->!PortableZipline.hasActive(player(s)),100);c.waitFor(m->anchored(m)==null,100);
server.runOnServer(s->prepare(player(s)));c.waitFor(m->Math.abs(m.player.getX()-2.5)<.1 && Math.abs(m.player.getY()-82)<.1,200);
launch(c);c.waitFor(m->anchored(m)!=null,300);
server.runOnServer(s->{var p=player(s);var at=p.blockPosition().east(3);for(int y=-1;y<=3;y++)for(int z=-1;z<=1;z++)p.level().setBlockAndUpdate(at.offset(0,y,z),Blocks.STONE.defaultBlockState());});
server.waitFor(s->!PortableZipline.hasActive(player(s)),200);
double releaseY=server.computeOnServer(s->player(s).getY());c.waitTicks(12);
server.runOnServer(s->check(player(s).getY()<releaseY-1 && !player(s).isNoGravity(),"After hitting obstacle native gravity actually drops the player"));
c.waitFor(m->anchored(m)==null,100);c.takeScreenshot("ziplines242-collision-fall");
server.runOnServer(s->{var p=player(s);p.teleportTo(2.5,82,2.5);p.setDeltaMovement(Vec3.ZERO);
p.level().setBlockAndUpdate(new BlockPos(2,81,2),Blocks.STONE.defaultBlockState());
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.LEAD,2));});
c.waitFor(m->m.player.getMainHandItem().is(Items.LEAD) && Math.abs(m.player.getZ()-2.5)<.1,200);
c.getInput().lookAt(new BlockPos(3,84,3));c.getInput().holdKey(o->o.keyShift);c.waitTicks(3);
c.getInput().holdKey(o->o.keyUse);c.waitTicks(2);c.getInput().releaseKey(o->o.keyUse);c.getInput().releaseKey(o->o.keyShift);
server.waitFor(s->((PulleyHeadBlockEntity)player(s).level().getBlockEntity(new BlockPos(3,84,3))).connectedPos().isEmpty(),100);
server.runOnServer(s->check(player(s).getMainHandItem().getCount()==2
&& ((PulleyHeadBlockEntity)player(s).level().getBlockEntity(new BlockPos(20,84,3))).connectedPos().isEmpty(),"Native sneak-use unlinks both endpoints without consuming a lead"));
System.out.println("ZIPLINES242_CLIENT_PASS backend=vulkan orientations=12 recipes=server charged-native-input=pass straight-cables=pass collision-native-fall=pass native-unlink=pass FR/EN=pass");
} finally {intro.set(false);c.getInput().releaseKey(o->o.keyUse);c.getInput().releaseKey(o->o.keyShift);}
}
private static void launch(ClientGameTestContext c) {
c.runOnClient(m->{m.player.setYRot(-90);m.player.setXRot(-12);});c.waitTicks(3);
c.getInput().holdKey(o->o.keyUse);c.waitTicks(26);c.getInput().releaseKey(o->o.keyUse);
}
private static void prepare(ServerPlayer p) {
PortableZipline.stop(p);p.getInventory().clearContent();p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Ziplines.GRAPPLING_ROD));
p.getInventory().setItem(1,new ItemStack(Items.LEAD,4));p.setDeltaMovement(Vec3.ZERO);p.teleportTo(2.5,82,.5);p.setYRot(-90);p.setXRot(-12);
}
private static ServerPlayer player(MinecraftServer s) {return s.getPlayerList().getPlayers().getFirst();}
private static GrapplingHookEntity anchored(Minecraft m) {
if(m.level==null)return null;for(var e:m.level.entitiesForRendering())if(e instanceof GrapplingHookEntity hook && hook.isAnchored())return hook;return null;
}
}
@@ -23,7 +23,8 @@ public final class Bloom130ClientChecks implements FabricClientGameTest {
String ns=name.equals("ruby")||name.equals("sapphire")?"sanctuary:":"minecraft:";result.add(ns+name+"_ore");result.add(ns+"deepslate_"+name+"_ore");}
result.add("minecraft:nether_quartz_ore");result.add("minecraft:nether_gold_ore");return List.copyOf(result);}
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{preferences();VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);VanillaLight.setPbr(false);VanillaLight.setSsgi(false);analyseTextures();VanillaLight.setSaturation(100);VanillaLight.setPixelShadows(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);});
System.out.println("BLOOM256_RENDERER sodium="+FabricLoader.getInstance().isModLoaded("sodium"));
c.runOnClient(m->{preferences();VanillaLight.setEnabled(true);VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);VanillaLight.setPbr(false);VanillaLight.setSsgi(false);analyseTextures();VanillaLight.setSaturation(100);VanillaLight.setPixelShadows(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);});
server.runOnServer(s->{command(s,"time set 18000");
for(int x=4;x<=20;x++)for(int z=4;z<=20;z++){
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),Blocks.CONCRETE.black().defaultBlockState());
@@ -102,7 +103,7 @@ public final class Bloom130ClientChecks implements FabricClientGameTest {
private static BlockPos orePos(int i){return new BlockPos(8+i%5,65,8+i/5);}
private static void preferences(){try{
var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.deleteIfExists(path);forgetPreferences();
check(VanillaLight.pixelShadows()&&VanillaLight.shadowDistance()==128&&VanillaLight.shadowPixels()==16&&VanillaLight.edgeDistance()==32,"Fresh requested defaults: shadows ON, 128; highlights 32; shared precision 16");
check(VanillaLight.profile()==ShaderProfile.MEDIUM&&VanillaLight.pixelShadows()&&VanillaLight.shadowDistance()==64&&VanillaLight.shadowPixels()==16&&VanillaLight.edgeDistance()==16,"Fresh Medium: shadows ON, 64; highlights 16; shared precision 16");
check(VanillaLight.bloom()&&VanillaLight.oreEmission()&&VanillaLight.edgeHighlights()&&VanillaLight.bloomIntensity()==20&&VanillaLight.bloomRadius()==50,"Bloom, ore emission and edge highlights default to ON");
Files.writeString(path,"{\"bloom\":false,\"oreEmission\":false,\"edgeHighlights\":false,\"pixelShadows\":false,\"shadowDistance\":64,\"shadowPixels\":8,\"edgeDistance\":16}");forgetPreferences();
check(!VanillaLight.bloom()&&!VanillaLight.oreEmission()&&!VanillaLight.edgeHighlights()&&!VanillaLight.pixelShadows()&&VanillaLight.shadowDistance()==64&&VanillaLight.shadowPixels()==8&&VanillaLight.edgeDistance()==16,"Existing user choices are retained");
@@ -0,0 +1,99 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.client.shader.*;
import fr.koka.sanctuary.recipes.RecipeService;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
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.loader.api.FabricLoader;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.options.OptionsScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.*;
import net.minecraft.world.item.crafting.*;
/** Real launches with independently absent client mods; server knowledge remains authoritative. */
public final class External257ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
boolean jei=FabricLoader.getInstance().isModLoaded("jei");
boolean sound=FabricLoader.getInstance().isModLoaded("sound_physics_remastered");
boolean sodium=FabricLoader.getInstance().isModLoaded("sodium");
c.runOnClient(m->{
require(JeiIntegration.installed()==jei,"Optional catalogue presence detected");
var saved=VanillaLight.exportSettings();
try {
for(var profile:ShaderProfile.values())if(profile!=ShaderProfile.CUSTOM){
VanillaLight.setProfile(profile);
require(VanillaLight.sunshaftIntensity()==20,"Preset rays at twenty percent: "+profile);
if(profile==ShaderProfile.MEDIUM)require(VanillaLight.ssr(),"Medium retains SSR");
require(VanillaLight.shadowDistance()<256&&VanillaLight.sunshaftDistance()<256,"Experimental distances are not selected by a preset");
}
} finally { try { VanillaLight.importSettings(saved); } catch(java.io.IOException e){throw new AssertionError(e);} }
for(String locale:List.of("fr_fr","en_us")){
m.getLanguageManager().setSelected(locale);
m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
m.gui.setScreen(new OptionsScreen(new TitleScreen(false),m.options));
String title=Component.translatable("sanctuary.sound_physics.title").getString();
require(widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast)
.anyMatch(b->b.getMessage().getString().equals(title))==sound,"Sound options follow mod presence: "+locale);
require(!RecipeClient.label("jei_missing").getString().contains("sanctuary.recipes."),"Missing catalogue message translated: "+locale);
}
m.gui.setScreen(new TitleScreen(false));
});
var onboarding=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen 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());onboarding.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{
s.setName("Sanctuary external257 "+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:sanctuary")).findFirst().orElseThrow());
}).create()){
world.getConnection().waitForChunksDownload(2400);
c.waitFor(m->m.player!=null&&ProgressionClient.active()&&RecipeClient.ready&&m.gui.screen()==null,1200);
if(jei)c.waitFor(m->JeiIntegration.available(),1200);
var server=world.getServer();
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();
var input=CraftingInput.of(2,2,List.of(new ItemStack(Items.OAK_PLANKS),new ItemStack(Items.OAK_PLANKS),new ItemStack(Items.OAK_PLANKS),new ItemStack(Items.OAK_PLANKS)));
var recipe=s.getRecipeManager().getRecipeFor(RecipeType.CRAFTING,input,s.overworld()).orElseThrow();
require(recipe.value().assemble(input).is(Items.CRAFTING_TABLE),"Manual crafting works without catalogue purchase");
p.getInventory().setItem(0,new ItemStack(Items.OAK_PLANKS,8));p.giveExperienceLevels(4-p.experienceLevel);RecipeService.refresh(p);
});
c.waitFor(m->RecipeClient.knowledge!=null&&RecipeClient.knowledge.recipes.contains("minecraft:crafting_table")&&RecipeClient.xp>=4,120);
c.runOnClient(m->{m.gui.setScreen(new RecipeScreen(null));button(m.gui.screen(),"sanctuary.recipes.buy").onPress(enter());});
c.waitFor(m->RecipeClient.allowed()&&RecipeClient.knowledge.learned(),120);
c.runOnClient(m->{
require(button(m.gui.screen(),"sanctuary.recipes.open").active==jei,"Catalogue button follows installed runtime");
if(!jei){JeiIntegration.open();require(m.gui.screen() instanceof RecipeScreen,"Absent catalogue is safe to invoke");}
});
c.takeScreenshot("external257-jei-"+jei+"-sound-"+sound+"-sodium-"+sodium);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();s.getWorldData().setAllowCommands(true);s.getPlayerList().op(p.nameAndId());RecipeService.refresh(p);});
c.waitFor(m->RecipeClient.operatorAllowed,120);
c.waitFor(m->RecipeClient.operator,120);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();s.getWorldData().setAllowCommands(false);s.getPlayerList().deop(p.nameAndId());RecipeService.refresh(p);});
c.waitFor(m->!RecipeClient.operatorAllowed&&!RecipeClient.operator,120);
if(jei&&Boolean.getBoolean("sanctuary.test.external257Integration"))Jei257ClientChecks.run(c,server);
}
System.out.println("EXTERNAL257_CLIENT_PASS jei="+jei+" sound="+sound+" sodium="+sodium+" world, manual crafting, discovery, purchase, permissions, optional menus, FR/EN, preset rays20");
if(Boolean.getBoolean("sanctuary.test.external257Integration")){
if(sound)new fr.koka.sanctuary.client.sound.SoundPhysics252ClientChecks().runTest(c);
}
}
private static Stream<GuiEventListener> widgets(GuiEventListener root){return Stream.concat(Stream.of(root),root instanceof ContainerEventHandler c?c.children().stream().flatMap(External257ClientChecks::widgets):Stream.empty());}
private static Button button(Screen screen,String key){String text=Component.translatable(key).getString();return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals(key)||b.getMessage().getString().equals(text)).findFirst().orElseThrow(()->new AssertionError("Missing button "+key));}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,138 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import com.mojang.blaze3d.platform.NativeImage;
import fr.koka.sanctuary.gametest.Water144Metrics;
import java.nio.file.*;
import java.util.*;
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 net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.gamerules.GameRules;
import org.joml.Matrix4f;
import org.joml.Vector4f;
/** Real resource-pack alpha oracle and before/after fixtures on a fresh seed 122 world. */
final class LeafShadows255ClientChecks {
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{m.options.framerateLimit().set(net.minecraft.client.Options.UNLIMITED_FRAMERATE_CUTOFF);
m.options.inactivityFpsLimit().set(net.minecraft.client.InactivityFpsLimit.MINIMIZED);m.getWindow().setWindowed(1920,1080);});
c.waitFor(m->m.gameRenderer.mainRenderTarget().width==1920&&m.gameRenderer.mainRenderTarget().height==1080,200);
if(!Boolean.getBoolean("sanctuary.test.leaf255PerformanceOnly"))accuracy(c,server);
benchmark(c,server);
System.out.println("LEAF255_PASS");
}
private static void fixture(TestServerContext server,int radius,int layers){
server.runOnServer(s->{
var level=s.overworld();
for(int x=-24;x<=24;x++)for(int z=-24;z<=24;z++){
level.setBlockAndUpdate(new BlockPos(x,64,z),Blocks.CONCRETE.white().defaultBlockState());
for(int y=65;y<=85;y++)level.setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());
if(Math.abs(x)<=radius&&Math.abs(z)<=radius)for(int y=72;y<72+layers;y++)
level.setBlockAndUpdate(new BlockPos(x,y,z),Blocks.OAK_LEAVES.defaultBlockState().setValue(BlockStateProperties.PERSISTENT,true));
}
});
}
private static void accuracy(ClientGameTestContext c,TestServerContext server){
fixture(server,2,1);
server.runOnServer(s->command(s,"time set 6000"));camera(c,server,.5,68,.5,70);
Path directory=c.computeOnClient(m->m.getResourcePackDirectory().resolve("ambiance-leaf255-fixture"));
try{
Files.createDirectories(directory.resolve("assets/minecraft/textures/block"));
var mod=FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow();
Files.copy(mod.findPath("resourcepacks/template/pack.mcmeta").orElseThrow(),directory.resolve("pack.mcmeta"),StandardCopyOption.REPLACE_EXISTING);
c.runOnClient(m->{var repo=m.getResourcePackRepository();repo.reload();check(repo.addPack("file/ambiance-leaf255-fixture"),"Alpha fixture discovered");});
for(boolean center:new boolean[]{true,false}){
mask(directory,center,false);reload(c);
for(var profile:new ShaderProfile[]{ShaderProfile.LOW,ShaderProfile.MEDIUM}){
c.runOnClient(m->isolated(profile));
c.waitTicks(40);c.waitFor(m->PixelShadows.ready()&&PixelShadows.applied()&&m.levelRenderer.hasRenderedAllSections(),1200);
shadows(c,false);var off=capture(c,null);shadows(c,true);var on=capture(c,"leaf255-alpha-"+profile.id()+"-"+center);
var points=points(c,off);
int checked=0,matched=0;double dark=0,lit=0;int nd=0,nl=0;
for(var point:points){
double opacity=(brightness(off.pixels()[point[0]])-brightness(on.pixels()[point[0]]))/(double)Math.max(1,brightness(off.pixels()[point[0]]));
boolean expected=point[1]==(center?1:0);checked++;
if(expected){dark+=opacity;nd++;if(opacity>.18)matched++;}
else{lit+=opacity;nl++;if(Math.abs(opacity)<.08)matched++;}
}
check(checked>=150&&matched>=checked*.94,"Loaded alpha follows the actual texel pattern in "+profile+": "+matched+"/"+checked);
check(nd>30&&nl>30&&dark/nd>.25&&Math.abs(lit/nl)<.06,"Visible leaf holes and shadows in "+profile);
int draws=c.computeOnClient(m->PixelShadows.depthDraws());c.waitTicks(15);
c.runOnClient(m->check(PixelShadows.depthDraws()==draws,"Static real-alpha map remains cached"));
unchanged(on,capture(c,null),"Real leaf texture remains stable at fixed camera");
System.out.println("LEAF255_ALPHA "+profile+" center="+center+" matched="+matched+"/"+checked+" dark="+dark/nd+" lit="+lit/nl);
}
}
mask(directory,true,true);reload(c);c.runOnClient(m->isolated(ShaderProfile.MEDIUM));activeFrames(c);
var samples=new ArrayList<Double>();
for(int n=0;n<12;n++){
var frame=capture(c,null);var points=points(c,frame);double sum=0;int count=0;
for(var p:points)if(p[1]==1){sum+=brightness(frame.pixels()[p[0]]);count++;}
samples.add(sum/count);c.waitTicks(10);
}
double spread=Collections.max(samples)-Collections.min(samples);
check(spread>12,"Animated leaf alpha updates the cached depth map: spread="+spread);
System.out.println("LEAF255_ANIMATION spread="+spread);
}catch(Exception e){throw new AssertionError(e);}
finally{
c.runOnClient(m->m.getResourcePackRepository().removePack("file/ambiance-leaf255-fixture"));reload(c);
}
fixture(server,12,1);server.runOnServer(s->command(s,"time set 3000"));camera(c,server,0,69,0,70);look(c,server,0,60);
for(var profile:new ShaderProfile[]{ShaderProfile.LOW,ShaderProfile.MEDIUM}){
c.runOnClient(m->{VanillaLight.setProfile(profile);VanillaLight.setEnabled(true);});activeFrames(c);capture(c,"leaf255-native-"+profile.id());
}
System.out.println("LEAF255_ACCURACY_PASS native texture alpha, LOW/MEDIUM, resource override/reload, cached static map, animated leaves");
}
private static void mask(Path directory,boolean center,boolean animated)throws Exception{
Path file=directory.resolve("assets/minecraft/textures/block/oak_leaves.png");
try(var image=new NativeImage(16,animated?32:16,false)){
for(int y=0;y<image.getHeight();y++)for(int x=0;x<16;x++){
boolean inside=x>=4&&x<12&&y%16>=4&&y%16<12;
boolean filled=inside==(y<16?center:!center);image.setPixel(x,y,filled?0xFF58A840:0);
}
image.writeToFile(file);
}
Path metadata=directory.resolve("assets/minecraft/textures/block/oak_leaves.png.mcmeta");
if(animated)Files.writeString(metadata,"{\"animation\":{\"frametime\":40,\"frames\":[0,1]}}");else Files.deleteIfExists(metadata);
}
private static void reload(ClientGameTestContext c){var future=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->future.isDone()&&m.gui.overlay()==null,1200);future.join();c.waitTicks(30);}
private static void isolated(ShaderProfile profile){
VanillaLight.setProfile(profile);VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);
VanillaLight.setSunshafts(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setLensFlare(false);
VanillaLight.setEdgeHighlights(false);VanillaLight.setColoredLights(false);VanillaLight.setPbr(false);VanillaLight.setSsr(false);VanillaLight.setSsgi(false);
}
private static List<int[]> points(ClientGameTestContext c,Frame frame){return c.computeOnClient(m->{
var camera=m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
var vp=new Matrix4f(camera.projectionMatrix).mul(camera.viewRotationMatrix);var samples=new ArrayList<int[]>();
for(int bx=-2;bx<=2;bx++)for(int bz=-2;bz<=2;bz++)for(int x=0;x<4;x++)for(int z=0;z<4;z++){
double wx=bx+(x+.5)/4,wz=bz+(z+.5)/4;
var p=vp.transform(new Vector4f((float)(wx-camera.pos.x),(float)(65-camera.pos.y),(float)(wz-camera.pos.z),1));
int px=(int)((p.x/p.w*.5+.5)*frame.width()),py=(int)((.5-p.y/p.w*.5)*frame.height());
if(px>=12&&px<frame.width()-12&&py>=12&&py<frame.height()-12)samples.add(new int[]{py*frame.width()+px,x>=1&&x<=2&&z>=1&&z<=2?1:0});
}
return samples;
});}
private static void benchmark(ClientGameTestContext c,TestServerContext server){
fixture(server,20,3);camera(c,server,0,69,0,70);look(c,server,0,55);c.waitTicks(80);
var result=new LinkedHashMap<String,Object>();var samples=new LinkedHashMap<String,Object>();
c.runOnClient(m->{var info=com.mojang.blaze3d.systems.RenderSystem.getDevice().getDeviceInfo();result.put("backend",info.backendName());result.put("gpu",info.name());});
result.put("measurement","renderFrame wall time in ms; submission/stalls included, not isolated GPU time or game FPS");result.put("scene","flat122-41x41-three-leaf-layers-1080p");
for(boolean moving:new boolean[]{false,true}){
server.runOnServer(s->{if(s.getGlobalGameRules().get(GameRules.ADVANCE_TIME)!=moving)command(s,"gamerule "+GameRules.ADVANCE_TIME.id()+" "+moving);command(s,"time add 1");command(s,"time set 3000");});
for(int repeat=0;repeat<2;repeat++)for(var profile:repeat==0?new ShaderProfile[]{ShaderProfile.LOW,ShaderProfile.MEDIUM}:new ShaderProfile[]{ShaderProfile.MEDIUM,ShaderProfile.LOW}){
c.runOnClient(m->{VanillaLight.setProfile(profile);VanillaLight.setEnabled(true);});c.waitTicks(60);c.waitFor(m->PixelShadows.ready()&&(profile==ShaderProfile.LOW||ProceduralPbr.ready()),2400);
int before=c.computeOnClient(m->PixelShadows.depthDraws());c.runOnClient(m->Water144Metrics.start());c.waitTicks(100);var v=c.computeOnClient(m->Water144Metrics.stopDetailed());
int redraws=c.computeOnClient(m->PixelShadows.depthDraws())-before;
var metrics=Map.of("frames",v[0],"medianMs",v[1],"p95Ms",v[2],"depthDraws",redraws);
String key=(moving?"moving":"static")+"-"+repeat+"-"+profile.id();samples.put(key,metrics);System.out.println("LEAF255_PERF "+key+" "+metrics);
}
}
result.put("samples",samples);
try{Files.writeString(Path.of("leaf255-performance.json"),new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(result));}catch(Exception e){throw new AssertionError(e);}
System.out.println("LEAF255_PERF_PASS");
}
}
@@ -11,7 +11,9 @@ final class PbrRefresh142ClientChecks {
static void refresh(ClientGameTestContext c,TestServerContext s,Frame reference){
int entries=c.computeOnClient(m->ProceduralPbr.terrainEntries()),builds=c.computeOnClient(m->ProceduralPbr.terrainBuilds());check(entries>0,"PBR fixture has visible sections");
for(int i=0;i<12;i++){
c.runOnClient(m->{for(var section:m.levelRenderer.visibleSections()){var p=section.getRenderOrigin();ProceduralPbr.dirtySection(p.getX()>>4,p.getY()>>4,p.getZ()>>4);}
c.runOnClient(m->{int[] visited={0};SurfaceSections.visit(VanillaLight.materialDistance(),(key,p,storage)->{
visited[0]++;ProceduralPbr.dirtySection(p.getX()>>4,p.getY()>>4,p.getZ()>>4);});
check(visited[0]>0,"Refresh oracle invalidates real rendered sections with either terrain renderer");
check(ProceduralPbr.terrainEntries()==entries,"Invalidation retains every published PBR section");});
c.waitTicks(1);unchanged(reference,capture(c,null),"PBR remains visible throughout repeated refreshes");
}
@@ -0,0 +1,159 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.gametest.Frame256Metrics;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.gametest.Water144Metrics;
import fr.koka.sanctuarytest.SharedIsland196;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import jdk.jfr.Recording;
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.minecraft.client.*;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.levelgen.Heightmap;
/** Real generated Grand/32 scene. Does not open, edit or regenerate an existing save. */
public final class Performance256ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
var intro = new AtomicBoolean(true);
c.runOnClient(m -> {
var info = com.mojang.blaze3d.systems.RenderSystem.getDevice().getDeviceInfo();
check(info.backendName().equalsIgnoreCase("vulkan"), "Requires native Vulkan");
check(Runtime.getRuntime().maxMemory() >= 7L*1024*1024*1024
&& Runtime.getRuntime().maxMemory() <= 9L*1024*1024*1024, "Comparison uses an 8 GiB Java ceiling");
m.options.renderDistance().set(5); m.options.simulationDistance().set(5);
m.options.framerateLimit().set(Options.UNLIMITED_FRAMERATE_CUTOFF);
m.options.inactivityFpsLimit().set(InactivityFpsLimit.MINIMIZED);
m.options.pauseOnLostFocus = false; m.options.bobView().set(false);
m.options.fullscreen().set(false); m.options.exclusiveFullscreen().set(false);
IntroClient.setQuickTestIntro(true);
VanillaLight.setEnabled(false);
});
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);
}
});
var result = new LinkedHashMap<String,Object>();
var samples = new LinkedHashMap<String,Object>(); result.put("samples",samples);
try (var world = c.worldBuilder().adjustSettings(s -> {
s.setName("Performance256-Grand-"+UUID.randomUUID()); s.setSeed("0"); s.setAllowCommands(true);
s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);
var preset=s.getSettings().worldgenLoadContext().lookupOrThrow(Registries.WORLD_PRESET)
.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET,SharedIsland196.Size.LARGE.id()));
s.setWorldType(new WorldCreationUiState.WorldTypeEntry(preset));
}).create()) {
c.waitFor(m -> m.player != null && ProgressionClient.active() && m.gui.screen() == null && !IntroSpawnFlash.active(), 4800);
c.runOnClient(m -> m.getWindow().setWindowed(1920,1080));
c.waitFor(m -> m.gameRenderer.mainRenderTarget().width==1920&&m.gameRenderer.mainRenderTarget().height==1080,200);
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(net.minecraft.world.level.gamerules.GameRules.ADVANCE_TIME))
command(s,"gamerule "+net.minecraft.world.level.gamerules.GameRules.ADVANCE_TIME.id()+" false");
command(s,"time set 3000"); command(s,"gamemode spectator @a");
});
double height = server.computeOnServer(s -> (double)s.overworld().getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,-40,40)+12);
place(c,server,-40,height,40,225,15);
c.runOnClient(m -> {m.options.renderDistance().set(32);m.options.broadcastOptions();});
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
c.waitFor(m -> m.level.getChunkSource().getLoadedChunksCount()>3000,12000);
c.waitTicks(400);
server.runOnServer(s -> {
try {
var field=fr.koka.sanctuary.knowledge.census.TerrainCensus.class.getDeclaredField("SERVERS");
field.setAccessible(true);
check(!((Map<?,?>)field.get(null)).containsKey(s), "Ordinary play does not activate the diagnostic terrain census");
} catch (ReflectiveOperationException e) {throw new AssertionError(e);}
});
c.runOnClient(m -> {
var info=com.mojang.blaze3d.systems.RenderSystem.getDevice().getDeviceInfo();
result.put("backend",info.backendName()); result.put("gpu",info.name()); result.put("driver",info.driverInfo());
result.put("heapMaxBytes",Runtime.getRuntime().maxMemory()); result.put("renderDistanceChunks",32);
result.put("loadedChunks",m.level.getChunkSource().getLoadedChunksCount());
result.put("scene","sanctuary:island231_large, seed 0, fresh world"); result.put("width",1920);result.put("height",1080);
result.put("vsync",m.options.enableVsync().get());
});
for (String mode : List.of("off","shadows","shafts","medium","custom")) {
c.runOnClient(m -> configure(mode)); c.waitTicks(120);
place(c,server,-40,height,40,225,15); c.waitTicks(100);
sample(c,server,samples,mode+"-terrain",height,false);
place(c,server,560,height,0,270,-5); c.waitTicks(180);
sample(c,server,samples,mode+"-void",height,false);
place(c,server,-120,height,40,270,15); c.waitTicks(180);
sample(c,server,samples,mode+"-flight",height,true);
}
System.out.println("PERFORMANCE256_PASS "+result);
} finally {
intro.set(false); Frame256Metrics.recording=false; Water144Metrics.recording=false;
try {Files.writeString(Path.of("performance256.json"),new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(result));}
catch (Exception e) {throw new AssertionError(e);}
}
}
private static void place(ClientGameTestContext c,TestServerContext server,double x,double y,double z,float yaw,float pitch){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(p.level(),x,y,z,Set.of(),yaw,pitch,true);});
c.waitTicks(20);
c.runOnClient(m->{
System.out.println("PERFORMANCE256_CAMERA requested="+x+","+y+","+z+" actual="+m.player.position());
check(m.player.position().distanceToSqr(new net.minecraft.world.phys.Vec3(x,y,z))<1,"Camera is at the intended generated scene");
m.player.setYRot(yaw);m.player.yRotO=yaw;m.player.setXRot(pitch);m.player.xRotO=pitch;m.options.fov().set(70);
});
}
private static net.minecraft.client.input.KeyEvent enter() { return new net.minecraft.client.input.KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0); }
private static void configure(String mode) {
VanillaLight.setProfile(ShaderProfile.MEDIUM); VanillaLight.setEnabled(!mode.equals("off"));
if(mode.equals("shadows")||mode.equals("shafts")) {
VanillaLight.setPixelShadows(mode.equals("shadows")); VanillaLight.setSunshafts(mode.equals("shafts"));
VanillaLight.setBloom(false); VanillaLight.setOreEmission(false); VanillaLight.setPbr(false); VanillaLight.setSsr(false);
VanillaLight.setSsgi(false); VanillaLight.setColoredLights(false); VanillaLight.setEdgeHighlights(false); VanillaLight.setLensFlare(false);
}
if(mode.equals("custom")) {
var settings=VanillaLight.exportSettings(); settings.addProperty("qualityProfile","custom");settings.addProperty("performanceProfile","custom");
settings.addProperty("shadowDistance",128);settings.addProperty("pbrDistance",64);settings.addProperty("ssrDistance",64);
settings.addProperty("ssgiIntensity",35);settings.addProperty("sunshaftIntensity",35);
try {VanillaLight.importSettings(settings);}catch(Exception e){throw new AssertionError(e);}
}
}
private static void sample(ClientGameTestContext c, TestServerContext server, Map<String,Object> samples, String key, double height, boolean moving) {
int draws=c.computeOnClient(m -> PixelShadows.depthDraws());
Recording recording=null;
try {
if(moving&&key.startsWith("custom")) {recording=new Recording(jdk.jfr.Configuration.getConfiguration("profile"));recording.start();}
c.runOnClient(m -> {Frame256Metrics.start();Water144Metrics.start();});
if(moving) for(int n=1;n<=40;n++) {
double x=-120+n*6.;
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();
p.teleportTo(p.level(),x,height,40,Set.of(),270,15,true);
}); c.waitTicks(4);
} else c.waitTicks(160);
var values=c.computeOnClient(m -> {
var map=new LinkedHashMap<>(Frame256Metrics.stop());var cpu=Water144Metrics.stopDetailed();
map.put("renderCpuMedianMs",cpu[1]);map.put("renderCpuP95Ms",cpu[2]);
map.put("depthRedraws",PixelShadows.depthDraws()-draws);map.put("settings",VanillaLight.exportSettings());
map.put("loadedChunks",m.level.getChunkSource().getLoadedChunksCount());return map;
});
samples.put(key,values); System.out.println("PERFORMANCE256_SAMPLE "+key+" "+values);
c.takeScreenshot("performance256-"+key);
} catch(Exception e) {throw new AssertionError(e);} finally {
Frame256Metrics.recording=false;Water144Metrics.recording=false;
if(recording!=null) {try {recording.stop();recording.dump(Path.of("performance256-"+key+".jfr"));recording.close();}catch(Exception e){throw new AssertionError(e);}}
}
}
}
@@ -91,6 +91,9 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
m.options.simulationDistance().set(5);
m.options.bobView().set(false);
m.options.pauseOnLostFocus = false;
// Falling leaf particles otherwise change the supposedly static GPU comparisons.
m.options.particles().set(net.minecraft.server.level.ParticleStatus.MINIMAL);
m.particleEngine.clearParticles();
m.options.setCameraType(CameraType.FIRST_PERSON);
String transparency = System.getProperty("sanctuary.test.improvedTransparency");
if (transparency != null) m.options.improvedTransparency().set(Boolean.parseBoolean(transparency));
@@ -150,6 +153,12 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
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);
if(Boolean.getBoolean("sanctuary.test.zenith258")){Zenith258ClientChecks.run(c,server);return;}
if(Boolean.getBoolean("sanctuary.test.leaf255")){LeafShadows255ClientChecks.run(c,server);return;}
if(Boolean.getBoolean("sanctuary.test.shaderOptions255")){ShaderOptions255ClientChecks.run(c);return;}
if(Boolean.getBoolean("sanctuary.test.shaderPerformance254")){ShaderPerformance254ClientChecks.run(c,server);return;}
if(Boolean.getBoolean("sanctuary.test.shaderProfiles254")){ShaderProfiles254ClientChecks.run(c,server);return;}
if(Boolean.getBoolean("sanctuary.test.anchor256Only")){noonAndFoliage(c,server);return;}
if(Boolean.getBoolean("sanctuary.test.water144")){Water144ClientChecks.run(c,server);return;}
if(coloredOnly){if(Boolean.getBoolean("sanctuary.test.directional141Only")){DirectionalLights141ClientChecks.run(c,server);return;}ColoredLights139ClientChecks.run(c,server);if(Boolean.getBoolean("sanctuary.test.colored140"))ColoredLights140ClientChecks.run(c,server);if(Boolean.getBoolean("sanctuary.test.directional141"))DirectionalLights141ClientChecks.run(c,server);return;}
if(sunshaftOnly){Sunshafts137ClientChecks.run(c,server);return;}
@@ -365,7 +374,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
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");
unchanged(thinOn, capture(c, null), "Static leaf alpha 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);
@@ -379,9 +388,12 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
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);
check(thin.mean > .02 && thick.mean >= thin.mean - .01 && solid.mean >= thick.mean - .01,
"Real alpha layers retain or increase foliage coverage, bounded by solid: thin=" + thin + " thick=" + thick + " solid=" + solid);
// Native cutout faces can overlap along the sun ray and cover every sampled pixel.
// The beta.255 controlled-alpha oracle checks light holes against the loaded texture;
// this dense native canopy checks coverage, live layer edits and world stability.
check(thin.dark > 100, "Native foliage casts a visible shadow: " + 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());
@@ -389,12 +401,16 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
});
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");
for(int pass=0;pass<3;pass++) {
camera(c, server, 7.99999, FLOOR + 5, 0, 60);
activeFrames(c);
// Keep the original readback timing twice; the last pass also saves
// the before image for diagnosis without relaxing the pixel oracle.
var boundary = capture(c, pass==2?"anchor-boundary-before-"+pass:null);
camera(c, server, 8.00001, FLOOR + 5, 0, 60);
activeFrames(c);
unchanged(boundary, capture(c, "anchor-boundary-after-"+pass), "Subpixel motion across 16-block anchor, pass "+pass);
}
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++)
@@ -453,7 +469,9 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");
Files.deleteIfExists(path);forgetPreferences();
check(VanillaLight.bloom()&&VanillaLight.bloomIntensity()==20&&VanillaLight.pbr()&&VanillaLight.ssgi(),"Fresh defaults: bloom20, PBR and SSGI ON");
check(VanillaLight.pbrIntensity()==50&&VanillaLight.ssrIntensity()==20&&VanillaLight.ssgiIntensity()==35,"PBR 50 and SSR 20 defaults, SSGI strength retained");
check(VanillaLight.profile()==ShaderProfile.MEDIUM&&VanillaLight.pbrIntensity()==50&&VanillaLight.ssr()
&&VanillaLight.ssrIntensity()==20&&VanillaLight.ssgiIntensity()==25,
"Fresh Medium: PBR 50, SSR 20 and subtle SSGI 25");
Files.writeString(path,"{\"pbr\":false,\"ssgi\":false,\"bloomIntensity\":73}");forgetPreferences();
check(!VanillaLight.pbr()&&!VanillaLight.ssgi()&&VanillaLight.bloomIntensity()==73,"Explicit personal toggles and intensity survive changed defaults");
Files.writeString(path,"{\"bloom\":false}");forgetPreferences();
@@ -670,11 +688,13 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
}
private record Options(int distance, int simulationDistance, int fov, boolean bob,
boolean pause, CameraType camera, String language, boolean improvedTransparency) {
boolean pause, CameraType camera, String language, boolean improvedTransparency,
net.minecraft.server.level.ParticleStatus particles) {
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(), m.options.improvedTransparency().get());
m.options.getCameraType(), m.getLanguageManager().getSelected(), m.options.improvedTransparency().get(),
m.options.particles().get());
}
void restore(Minecraft m) {
@@ -685,6 +705,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
m.options.pauseOnLostFocus = pause;
m.options.setCameraType(camera);
m.options.improvedTransparency().set(improvedTransparency);
m.options.particles().set(particles);
m.getLanguageManager().setSelected(language);
m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
}
@@ -0,0 +1,83 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import java.nio.file.*;
import com.mojang.blaze3d.platform.InputConstants;
import java.util.List;
import java.util.stream.Stream;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
/** Native navigation, visible file actions and all-or-nothing settings restoration. */
final class ShaderOptions255ClientChecks {
static void run(ClientGameTestContext c){
Path file=ShaderProfileFiles.directory().resolve("test-custom255.txt");
c.runOnClient(m->{
try {
for(var profile:ShaderProfile.values()){
VanillaLight.setProfile(profile);VanillaLight.setEnabled(true);VanillaLight.setSaturation(123);VanillaLight.setOreIntensity(73);VanillaLight.setWarmTorches(true);
if(profile==ShaderProfile.LOW)VanillaLight.setNaturalWater(false);
if(profile==ShaderProfile.MEDIUM)VanillaLight.setNaturalWater(true);
var expected=VanillaLight.exportSettings();var budget=VanillaLight.budget();
ShaderProfileFiles.exportTo(file);VanillaLight.setProfile(ShaderProfile.ULTRA);VanillaLight.setSaturation(1);
var destination=VanillaLight.exportSettings();
for(String key:List.of("sanctuaryNaturalWater","ordinaryNaturalWater"))
destination.addProperty(key,!expected.has(key)||!expected.get(key).getAsBoolean());
VanillaLight.importSettings(destination);ShaderProfileFiles.importFrom(file);forgetPreferences();
check(expected.equals(VanillaLight.exportSettings())&&budget.equals(VanillaLight.budget()),"Full custom profile and performance budget survive export/import and disk reload: "+profile);
}
var expected=VanillaLight.exportSettings();
Path invalid=file.resolveSibling("invalid255.txt");
for(String content:new String[]{"PROFILE=HIGH","ambiance.profile.format=1\nbloom=false\nsaturation=999","ambiance.profile.format=1\nbloom=false\npbr=perhaps","ambiance.profile.format=1\nunknownEffect=true",
"ambiance.profile.format=1\nsunshaftDistance=256","ambiance.profile.format=1\nsunshaftSteps=0","ambiance.profile.format=1\nsunshaftSteps=1024"}){
Files.writeString(invalid,content);boolean rejected=false;
try{ShaderProfileFiles.importFrom(invalid);}catch(java.io.IOException e){rejected=true;}
forgetPreferences();check(rejected&&expected.equals(VanillaLight.exportSettings()),"Invalid or foreign settings reject the whole import without changing saved preferences");
}
Files.deleteIfExists(invalid);Files.deleteIfExists(file);
}catch(Exception e){throw new AssertionError(e);}
});
for(String locale:List.of("fr_fr","en_us")){
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
VanillaLight.setProfile(ShaderProfile.MEDIUM);m.gui.setScreen(new ShaderOptionsScreen(null));});
c.waitTicks(4);c.runOnClient(m->m.gui.screen().clearFocus());c.waitTicks(2);c.takeScreenshot("ambiance255-root-"+locale);
for(String page:List.of("performance","lighting","atmosphere","materials","water","camera")){
press(c,"sanctuary.shaders.page."+page);c.waitTicks(3);c.takeScreenshot("ambiance255-"+page+"-"+locale);
if(page.equals("camera"))c.runOnClient(m->{
var slider=widgets(m.gui.screen()).filter(net.minecraft.client.gui.components.AbstractSliderButton.class::isInstance)
.map(net.minecraft.client.gui.components.AbstractSliderButton.class::cast).findFirst().orElseThrow();
int before=VanillaLight.saturation();slider.keyPressed(new KeyEvent(InputConstants.KEY_RETURN,0,0));
for(int i=0;i<8;i++)slider.keyPressed(new KeyEvent(InputConstants.KEY_RIGHT,0,0));
check(VanillaLight.saturation()>before&&VanillaLight.profile()==ShaderProfile.CUSTOM,"Keyboard edit in a category applies immediately and switches the preset to Custom");
});
if(page.equals("lighting")){press(c,"sanctuary.shaders.page.shadows");c.waitTicks(3);c.takeScreenshot("ambiance255-shadows-"+locale);c.runOnClient(m->m.gui.screen().onClose());}
c.runOnClient(m->m.gui.screen().onClose());
c.runOnClient(m->check(m.gui.screen().getTitle().getString().equals("Ambiance"),"Category returns to refreshed Ambiance root"));
}
}
c.runOnClient(m->{VanillaLight.setProfile(ShaderProfile.MEDIUM);VanillaLight.setSaturation(127);});
var expected=c.computeOnClient(m->VanillaLight.exportSettings());
press(c,"sanctuary.shaders.export");
c.runOnClient(m->widgets(m.gui.screen()).filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow().setValue(file.getFileName().toString()));
press(c,"sanctuary.shaders.export");c.waitTicks(3);c.takeScreenshot("ambiance255-export-success");
check(Files.isRegularFile(file),"Native Export button creates a portable txt profile");
c.runOnClient(m->{m.gui.screen().onClose();VanillaLight.setSaturation(0);});
press(c,"sanctuary.shaders.import");
c.runOnClient(m->widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast)
.filter(b->b.getMessage().getString().equals(file.getFileName().toString())).findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0)));
c.waitTicks(3);c.takeScreenshot("ambiance255-import-success");
c.runOnClient(m->check(expected.equals(VanillaLight.exportSettings()),"Native file selection restores the exported custom preset"));
c.runOnClient(m->{VanillaLight.setSaturation(1);m.gui.screen().onFilesDrop(List.of(file));check(expected.equals(VanillaLight.exportSettings()),"External file drop imports the same profile");m.gui.screen().onClose();m.gui.setScreen(null);});
try{Files.deleteIfExists(file);}catch(Exception e){throw new AssertionError(e);}
System.out.println("SHADER_OPTIONS255_PASS FR/EN categories, back navigation, native export/import/drop, custom budget persistence, invalid file atomic rejection");
}
private static Stream<GuiEventListener> widgets(ContainerEventHandler root){return root.children().stream().flatMap(w->w instanceof ContainerEventHandler child?Stream.concat(Stream.of(w),widgets(child)):Stream.of(w));}
private static void press(ClientGameTestContext c,String key){c.runOnClient(m->widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast)
.filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0)));}
}
@@ -0,0 +1,142 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import fr.koka.sanctuary.gametest.Water144Metrics;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.minecraft.client.InactivityFpsLimit;
import net.minecraft.client.Options;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.gamerules.GameRules;
/** Identical Vulkan fixtures for baseline and candidate; no personal world or preferences. */
final class ShaderPerformance254ClientChecks {
static void run(ClientGameTestContext c, TestServerContext server) {
c.runOnClient(m -> {
var info = com.mojang.blaze3d.systems.RenderSystem.getDevice().getDeviceInfo();
check(info.backendName().equalsIgnoreCase("vulkan"), "Performance fixture requires Vulkan");
m.options.framerateLimit().set(Options.UNLIMITED_FRAMERATE_CUTOFF);
m.options.inactivityFpsLimit().set(InactivityFpsLimit.MINIMIZED);
m.getWindow().setWindowed(1920, 1080);
});
c.waitFor(m -> m.gameRenderer.mainRenderTarget().width == 1920
&& m.gameRenderer.mainRenderTarget().height == 1080, 200);
server.runOnServer(s -> {
var level = s.overworld();
for (int x = -24; x <= 24; x++) for (int z = -24; z <= 24; z++) {
level.setBlockAndUpdate(new BlockPos(x, 64, z),
(x < 0 ? Blocks.CONCRETE.white() : Blocks.GOLD_ORE).defaultBlockState());
if (x > 3 && x < 20 && z > 3 && z < 20)
level.setBlockAndUpdate(new BlockPos(x, 65, z), Blocks.WATER.defaultBlockState());
if (x < -4 && Math.floorMod(x, 6) < 4 && Math.floorMod(z, 6) < 4)
level.setBlockAndUpdate(new BlockPos(x, 72, z), Blocks.OAK_LEAVES.defaultBlockState()
.setValue(BlockStateProperties.PERSISTENT, true));
}
});
camera(c, server, -2, 82, 1, 70);
c.waitTicks(80);
var result = new LinkedHashMap<String, Object>();
result.put("schema", 1);
result.put("scene", "flat122-water-gold-leaves-49x49");
result.put("measurement", "renderFrame wall time, milliseconds; includes submission/stalls, not isolated GPU time");
c.runOnClient(m -> {
var info = com.mojang.blaze3d.systems.RenderSystem.getDevice().getDeviceInfo();
result.put("backend", info.backendName()); result.put("gpu", info.name());
result.put("driver", info.driverInfo()); result.put("width", 1920); result.put("height", 1080);
result.put("renderDistanceChunks", m.options.renderDistance().get());
result.put("vsync", m.options.enableVsync().get());
});
var samples = new LinkedHashMap<String, Object>();
boolean movingSun = Boolean.getBoolean("sanctuary.test.shaderMovingSun254");
result.put("daylight", movingSun ? "advancing, reset to 3000 before each sample" : "frozen at 3000");
try {
for (int repeat = 0; repeat < 2; repeat++) {
String[] modes = movingSun ? new String[]{"off", "shadows128", "shafts128", "combined", "off"}
: new String[]{"off", "shadows64", "shadows128", "shafts64", "shafts128",
"bloom", "ore", "bloom-ore", "ssgi", "pbr", "pbr-ssr", "combined", "off"};
// Reverse the second sweep to expose warm-up/order bias.
for (int i = 0; i < modes.length; i++) {
String mode = modes[repeat == 0 ? i : modes.length - 1 - i];
c.runOnClient(m -> configure(mode));
if (movingSun) advanceSun(server);
c.waitTicks(60);
c.waitFor(m -> (!VanillaLight.pixelShadows() && !VanillaLight.sunshafts() || PixelShadows.ready())
&& (!VanillaLight.bloom() && !VanillaLight.oreEmission() || Bloom.ready())
&& (!VanillaLight.pbr() || ProceduralPbr.ready())
&& m.levelRenderer.hasRenderedAllSections(), 2400);
c.waitTicks(40);
c.runOnClient(m -> Water144Metrics.start());
c.waitTicks(100);
double[] values = c.computeOnClient(m -> Water144Metrics.stopDetailed());
check(values[0] > 10, "Enough rendered frame samples for " + mode);
String key = repeat + "-" + i + "-" + mode;
samples.put(key, Map.of("frames", values[0], "medianMs", values[1],
"p95Ms", values[2], "p99Ms", values[6], "maxMs", values[7]));
System.out.println("SHADER254_PERF " + key + " " + samples.get(key));
}
}
for(var profile:ShaderProfile.values()) {
if(profile==ShaderProfile.CUSTOM)continue;
c.runOnClient(m -> VanillaLight.setProfile(profile));
if (movingSun) advanceSun(server);
c.waitTicks(80);
c.waitFor(m -> (!VanillaLight.pixelShadows()||PixelShadows.ready())
&& (!VanillaLight.pbr()||ProceduralPbr.ready())
&& (!VanillaLight.bloom()||Bloom.ready()), 2400);
c.waitTicks(20);
capture(c,"performance254-"+profile.id());
c.runOnClient(m -> Water144Metrics.start()); c.waitTicks(100);
double[] values=c.computeOnClient(m -> Water144Metrics.stopDetailed());
samples.put("profile-"+profile.id(),Map.of("frames",values[0],"medianMs",values[1],
"p95Ms",values[2],"p99Ms",values[6],"maxMs",values[7]));
System.out.println("SHADER254_PERF profile-"+profile.id()+" "+samples.get("profile-"+profile.id()));
}
} finally {
Water144Metrics.recording = false;
result.put("samples", samples);
try { Files.writeString(Path.of("shader254-performance.json"),
new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(result)); }
catch (java.io.IOException e) { throw new AssertionError("Cannot save shader timings", e); }
}
System.out.println("SHADER254_PERF_PASS");
}
private static void advanceSun(TestServerContext server) {
server.runOnServer(s -> {
command(s, "time add 1");
command(s, "time set 3000");
if (!s.getGlobalGameRules().get(GameRules.ADVANCE_TIME))
command(s, "gamerule " + GameRules.ADVANCE_TIME.id() + " true");
});
}
private static void configure(String mode) {
VanillaLight.setEnabled(true); VanillaLight.setSaturation(100);
VanillaLight.setPixelShadows(false); VanillaLight.setSunshafts(false);
VanillaLight.setBloom(false); VanillaLight.setOreEmission(false);
VanillaLight.setLensFlare(false); VanillaLight.setEdgeHighlights(false);
VanillaLight.setColoredLights(false); VanillaLight.setSsgi(false); VanillaLight.setPbr(false);
VanillaLight.setSsr(false); VanillaLight.setShadowPixels(16); VanillaLight.setShadowDistance(128);
VanillaLight.setPbrDistance(64); VanillaLight.setSsrDistance(64);
if (mode.startsWith("shadows")) { VanillaLight.setPixelShadows(true); VanillaLight.setShadowDistance(Integer.parseInt(mode.substring(7))); }
if (mode.startsWith("shafts")) { VanillaLight.setSunshafts(true); VanillaLight.setShadowDistance(Integer.parseInt(mode.substring(6))); }
if (mode.contains("bloom")) VanillaLight.setBloom(true);
if (mode.contains("ore")) VanillaLight.setOreEmission(true);
if (mode.equals("ssgi")) VanillaLight.setSsgi(true);
if (mode.startsWith("pbr")) VanillaLight.setPbr(true);
if (mode.equals("pbr-ssr")) VanillaLight.setSsr(true);
if (mode.equals("combined")) {
VanillaLight.setPixelShadows(true); VanillaLight.setSunshafts(true);
VanillaLight.setBloom(true); VanillaLight.setOreEmission(true);
VanillaLight.setLensFlare(true); VanillaLight.setEdgeHighlights(true);
VanillaLight.setColoredLights(true); VanillaLight.setSsgi(true);
VanillaLight.setPbr(true); VanillaLight.setSsr(true);
}
}
}
@@ -0,0 +1,120 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import java.nio.file.Files;
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.network.chat.Component;
import net.minecraft.world.level.block.Blocks;
/** Persistence, explicit preset selection and real depth-map invalidation on a new world. */
final class ShaderProfiles254ClientChecks {
static void run(ClientGameTestContext c, TestServerContext server) {
c.runOnClient(m -> {
var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");
try {
Files.deleteIfExists(path); forgetPreferences();
check(VanillaLight.profile()==ShaderProfile.MEDIUM&&!VanillaLight.enabled(), "Fresh Medium preserves shader opt-in");
check(VanillaLight.pbrDistance()==32&&VanillaLight.shadowDistance()==64&&VanillaLight.ssr(), "Fresh Medium includes SSR and is not changed by legacy migrations");
Files.writeString(path,"{\"enabled\":true,\"pixelShadows\":false,\"sunshaftIntensity\":73,\"pbrIntensity\":64,\"shadowDistance\":256}");
forgetPreferences();
check(VanillaLight.profile()==ShaderProfile.CUSTOM&&VanillaLight.enabled()
&& !VanillaLight.pixelShadows()&&VanillaLight.sunshaftIntensity()==73
&& VanillaLight.pbrIntensity()==64&&VanillaLight.shadowDistance()==256,
"Existing visual choices stay Custom without an automatic reset");
for(var profile:ShaderProfile.values()) {
check(profile.budget().emissionDistance()<=128,"No automatic emitter budget selects 256 blocks: "+profile);
if(profile==ShaderProfile.CUSTOM)continue;
VanillaLight.setProfile(profile); forgetPreferences();
check(VanillaLight.shadowDistance()<=128&&VanillaLight.pbrDistance()<=128&&VanillaLight.ssrDistance()<=128&&VanillaLight.edgeDistance()<=128,"No preset selects experimental distances: "+profile);
check(VanillaLight.profile()==profile&&VanillaLight.budget().equals(profile.budget()), "Preset and budget persist: "+profile);
VanillaLight.setSaturation(101); forgetPreferences();
check(VanillaLight.profile()==ShaderProfile.CUSTOM&&VanillaLight.budget().equals(profile.budget()), "Custom edit retains effect budget: "+profile);
}
} catch(java.io.IOException e) { throw new AssertionError(e); }
VanillaLight.setEnabled(true);
});
for(var profile:ShaderProfile.values()) {
if(profile==ShaderProfile.CUSTOM)continue;
c.runOnClient(m -> VanillaLight.setProfile(profile));
c.waitTicks(60);
c.waitFor(m -> (!VanillaLight.pixelShadows()||PixelShadows.ready())
&& (!VanillaLight.pbr()||ProceduralPbr.ready())
&& (!VanillaLight.bloom()||Bloom.ready()), 2400);
c.waitTicks(20);
c.runOnClient(m -> {
check(VanillaLight.profile()==profile, "Rendering does not change selected profile");
if(profile==ShaderProfile.POTATO)check(!PixelShadows.applied()&&!Bloom.allocated()&&!ScreenSpaceGI.allocated(), "Potato releases costly targets");
else check(PixelShadows.applied()&&PixelShadows.shadowMap().width<=profile.budget().shadowResolution(), "Shadow budget respected: "+profile);
if(profile==ShaderProfile.MEDIUM||profile==ShaderProfile.HIGH||profile==ShaderProfile.ULTRA)
check(VanillaLight.ssr()&&PixelReflections.applied(), "SSR renders with the selected budget: "+profile);
});
capture(c,"profile254-"+profile.id());
}
c.runOnClient(m -> VanillaLight.setProfile(ShaderProfile.MEDIUM));
activeFrames(c);
int cached=c.computeOnClient(m -> PixelShadows.depthDraws());
c.waitTicks(30);
c.runOnClient(m -> check(PixelShadows.depthDraws()==cached, "Static map is reused across rendered frames"));
camera(c,server,-1,82,1,70);
c.waitTicks(30);
c.runOnClient(m -> check(PixelShadows.depthDraws()==cached,"Motion inside the shadow-map cell reuses caster geometry and depth"));
server.runOnServer(s -> s.overworld().setBlockAndUpdate(new BlockPos(0,73,0),Blocks.GOLD_BLOCK.defaultBlockState()));
c.waitFor(m -> PixelShadows.depthDraws()>cached&&PixelShadows.ready(), 1200);
int edited=c.computeOnClient(m -> PixelShadows.depthDraws());
camera(c,server,16,82,1,70);
c.waitFor(m -> PixelShadows.depthDraws()>edited&&PixelShadows.ready(), 1200);
int moved=c.computeOnClient(m -> PixelShadows.depthDraws());
server.runOnServer(s -> command(s,"time set 6000"));
c.waitFor(m -> PixelShadows.depthDraws()>moved&&PixelShadows.ready(), 1200);
mediumReflection(c,server);
for(String locale:new String[]{"fr_fr","en_us"}) {
c.runOnClient(m -> {
m.getLanguageManager().setSelected(locale);
m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
for(var profile:ShaderProfile.values())check(!Component.translatable(profile.translationKey()).getString().equals(profile.translationKey()), "Localized preset: "+locale);
m.gui.setScreen(new ShaderOptionsScreen(null));
});
c.waitTicks(4); c.takeScreenshot("shader254-menu-"+locale);
c.runOnClient(m -> m.gui.setScreen(null));
}
System.out.println("SHADER254_PROFILES_PASS presets, legacy preferences, custom budgets, FR/EN, cache reuse/edit/movement/sun");
}
private static void mediumReflection(ClientGameTestContext c,TestServerContext server) {
server.runOnServer(s -> {
for(int x=-18;x<=18;x++)for(int z=-4;z<=24;z++) {
for(int y=65;y<=85;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),Blocks.STONE.defaultBlockState());
if(z>=3&&z<=18)s.overworld().setBlockAndUpdate(new BlockPos(x,65,z),Blocks.WATER.defaultBlockState());
}
});
camera(c,server,.5,68,0,70);look(c,server,0,12);
double[] colour = new double[2];
var sources = new net.minecraft.world.level.block.Block[]{Blocks.CONCRETE.red(),Blocks.CONCRETE.blue()};
for(int n=0;n<sources.length;n++) {
var source=sources[n];
server.runOnServer(s -> {
for(int x=-12;x<=12;x++)for(int y=65;y<=77-Math.abs(x)/2;y++)
s.overworld().setBlockAndUpdate(new BlockPos(x,y,19),source.defaultBlockState());
});
c.runOnClient(m -> {VanillaLight.setProfile(ShaderProfile.MEDIUM);VanillaLight.setSsr(false);});
c.waitTicks(60); c.waitFor(m -> ProceduralPbr.ready()&&m.levelRenderer.hasRenderedAllSections(),1200);
var off=capture(c,null);
c.runOnClient(m -> VanillaLight.setProfile(ShaderProfile.MEDIUM));
c.waitTicks(10);
c.runOnClient(m -> check(PixelReflections.applied(),"Medium submits the SSR receiver pass"));
var on=capture(c,"profile254-medium-ssr-"+n);
long sum=0;int count=0;
for(int y=off.height()/2;y<off.height();y++)for(int x=off.width()/4;x<off.width()*3/4;x++) {
int i=y*off.width()+x,a=off.pixels()[i],b=on.pixels()[i];
sum+=(b>>16&255)-(a>>16&255)-(b&255)+(a&255);count++;
}
colour[n]=sum/(double)count;
}
check(colour[0]>colour[1]+.2,"Medium SSR follows the reflected landscape colour: "+colour[0]+" / "+colour[1]);
System.out.println("SHADER254_MEDIUM_SSR_SOURCE red="+colour[0]+" blue="+colour[1]);
}
}
@@ -23,6 +23,8 @@ public final class Sunshafts137ClientChecks implements FabricClientGameTest {
}
});
roof(server,0);camera(c,server,100.5,101,110.5,70);aim(c,server);c.waitTicks(50);
mediumDetail256(c,server);
roof(server,0);c.waitTicks(30);
var closedOff=capture(c,"shafts-closed-off");enable(c,true);var closed=capture(c,"shafts-closed-on");
check(gain(closedOff,closed)<300,"Sealed room receives no sunlight through opaque blocks: "+gain(closedOff,closed));
c.runOnClient(m->check(!PixelShadows.applied()&&PixelShadows.depthReady()&&!Bloom.allocated(),"Volume alone shares depth without surface shadows or bloom"));
@@ -48,11 +50,54 @@ public final class Sunshafts137ClientChecks implements FabricClientGameTest {
server.runOnServer(s->command(s,"time set 3000"));camera(c,server,100.5,101,110.5,70);aim(c,server);settle(c);
c.runOnClient(m->{VanillaLight.setPixelShadows(true);VanillaLight.setPbr(true);VanillaLight.setSsgi(true);VanillaLight.setBloom(true);VanillaLight.setLensFlare(true);VanillaLight.setEdgeHighlights(true);});activeFrames(c);capture(c,"shafts-combined");
c.runOnClient(m->{VanillaLight.setSunshafts(false);});c.waitTicks(10);c.runOnClient(m->check(!Sunshafts.allocated()&&PixelShadows.applied(),"OFF preserves independently enabled surface shadows"));
for(String locale:new String[]{"fr_fr","en_us"})c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());for(String suffix:new String[]{"sunshafts","sunshafts_help","sunshaft_intensity","sunshaft_intensity_help"}){String key="sanctuary.shaders."+suffix;check(!Component.translatable(key).getString().equals(key),"Localized volume controls: "+locale);}});
for(String locale:new String[]{"fr_fr","en_us"})c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());for(String suffix:new String[]{"sunshafts","sunshafts_help","sunshaft_intensity","sunshaft_intensity_help","sunshaft_distance","sunshaft_distance_help","sunshaft_steps","sunshaft_steps_help"}){String key="sanctuary.shaders."+suffix;check(!Component.translatable(key).getString().equals(key),"Localized volume controls: "+locale);}});
System.out.println("SUNSHAFT137_PASS slotGain="+low+" leafGain="+leaf+" openGain="+open+" closed room, offscreen sun, camera return, proximity, reload/resize, shared map, zero/night, preferences, FR/EN");
SunshaftWeather138ClientChecks.run(c,server);
}
static void roof(TestServerContext server,int mode){server.runOnServer(s->{for(int x=76;x<=124;x++)for(int z=76;z<=124;z++)s.overworld().setBlockAndUpdate(new BlockPos(x,113,z),(mode==3||mode==1&&Math.floorMod(z,8)<3?Blocks.AIR:mode==2?Blocks.OAK_LEAVES:Blocks.OAK_PLANKS).defaultBlockState().trySetValue(net.minecraft.world.level.block.state.properties.BlockStateProperties.PERSISTENT,true));});}
private static void mediumDetail256(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->m.getWindow().setWindowed(1920,1080));
c.waitFor(m->m.gameRenderer.mainRenderTarget().width==1920&&m.gameRenderer.mainRenderTarget().height==1080,200);
var saved=c.computeOnClient(m->VanillaLight.exportSettings());
try{
roof(server,1);
c.runOnClient(m->{VanillaLight.setProfile(ShaderProfile.MEDIUM);VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);
VanillaLight.setPixelShadows(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setLensFlare(false);
VanillaLight.setPbr(false);VanillaLight.setSsr(false);VanillaLight.setSsgi(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setColoredLights(false);
check(VanillaLight.sunshaftIntensity()==20&&VanillaLight.sunshaftSteps()==96&&VanillaLight.sunshaftDistance()==32,"Actual Medium uses subtle light and fine world-space sampling");});
enable(c,false);var off=capture(c,"shafts257-medium-off");enable(c,true);var on=capture(c,"shafts257-medium-20");
var contrast=new java.util.ArrayList<Integer>();
int dark=0;
for(int y=0;y<off.height();y++)for(int x=0;x<off.width();x++){
int i=y*off.width()+x,a=brightness(off.pixels()[i]),delta=brightness(on.pixels()[i])-a;
if(delta < -1)dark++;
if(y>off.height()/3&&a>24&&a<220)contrast.add(delta);
}
java.util.Collections.sort(contrast);int p95=contrast.get((int)(contrast.size()*.95));
check(p95>=1&&p95<=12,"Medium shafts are present but subtle at 20 percent: foreground p95="+p95);
check(dark==0,"Adding shafts cannot paint parasitic dark pixels: "+dark);
c.runOnClient(m->VanillaLight.setSunshaftSteps(192));settle(c);var reference=capture(c,"shafts257-reference-192");
var errors=new java.util.ArrayList<Integer>();var edges=new java.util.ArrayList<Integer>();
double sum=0;
for(int y=1;y<off.height()-1;y++)for(int x=1;x<off.width()-1;x++){
int i=y*off.width()+x,e=Math.abs(brightness(on.pixels()[i])-brightness(reference.pixels()[i]));
errors.add(e);sum+=e;
int a=brightness(off.pixels()[i]);
if(Math.abs(a-brightness(off.pixels()[i-1]))>24||Math.abs(a-brightness(off.pixels()[i-off.width()]))>24)edges.add(e);
}
java.util.Collections.sort(errors);java.util.Collections.sort(edges);
int detailP95=errors.get((int)(errors.size()*.95)),edgeP95=edges.get((int)(edges.size()*.95));
check(sum/errors.size()<.7&&detailP95<=2,"Medium approaches the doubled-density reference without caster replicas: mean="+sum/errors.size()+", p95="+detailP95);
check(edges.size()>100&&edgeP95<=2,"Silhouette reconstruction follows the fine reference: edges="+edges.size()+", p95="+edgeP95);
c.runOnClient(m->{VanillaLight.setSunshaftSteps(96);VanillaLight.setSunshaftDistance(8);});settle(c);var shortRange=capture(c,"shafts257-distance-8");
check(energy(off,on)>energy(off,shortRange)*1.05,"Independent shorter volume range attenuates scattering");
c.runOnClient(m->{VanillaLight.setSunshaftDistance(64);VanillaLight.setShadowDistance(16);});
c.runOnClient(m->check(VanillaLight.volumeDistance()==16,"Shared shadow map bounds the effective volume without extending surface shadows"));
c.runOnClient(m->{VanillaLight.setProfile(ShaderProfile.MEDIUM);VanillaLight.setEnabled(true);});
settle(c);capture(c,"shafts257-medium-combined");
System.out.println("SUNSHAFT257_DETAIL_PASS intensity=20 samplesPer32Blocks=96 foregroundP95="+p95+" referenceErrorMean="+sum/errors.size()+" referenceP95="+detailP95+" edgeP95="+edgeP95+" darkPixels="+dark);
}finally{c.runOnClient(m->{try{VanillaLight.importSettings(saved);}catch(java.io.IOException e){throw new AssertionError(e);}});}
}
static void aim(ClientGameTestContext c,TestServerContext server){var d=c.computeOnClient(m->PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle));look(c,server,Mth.wrapDegrees((float)Math.toDegrees(Math.atan2(-d.x,d.z))+25),-12);}
static void settle(ClientGameTestContext c){int before=c.computeOnClient(m->Sunshafts.renderedFrames());c.waitFor(m->Sunshafts.applied()&&PixelShadows.ready()&&Sunshafts.renderedFrames()>before+10,1200);c.waitTicks(30);}
static void enable(ClientGameTestContext c,boolean enabled){c.runOnClient(m->VanillaLight.setSunshafts(enabled));if(enabled)settle(c);else c.waitTicks(10);}
@@ -60,5 +105,5 @@ public final class Sunshafts137ClientChecks implements FabricClientGameTest {
// Screen blending has less headroom on a brighter vanilla wall. Remove that factor
// before comparing roofs, and exclude the roof itself where geometry changes.
static double energy(Frame off,Frame on){double sum=0;int n=0;for(int y=off.height()/3;y<off.height();y++)for(int x=0;x<off.width();x++){int i=y*off.width()+x;for(int shift:new int[]{0,8,16}){int a=off.pixels()[i]>>shift&255,b=on.pixels()[i]>>shift&255;if(a<240){sum+=Math.max(0,b-a)/(255.0-a);n++;}}}return sum/Math.max(1,n);}
private static void preferences(){try{var p=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.writeString(p,"{\"bloom\":false,\"pixelShadows\":false}");forgetPreferences();check(VanillaLight.sunshafts()&&VanillaLight.sunshaftIntensity()==35&&!VanillaLight.bloom()&&!VanillaLight.pixelShadows(),"Missing shaft settings preserve existing choices");VanillaLight.setSunshafts(false);VanillaLight.setSunshaftIntensity(73);forgetPreferences();check(!VanillaLight.sunshafts()&&VanillaLight.sunshaftIntensity()==73,"Custom shafts persist");for(int n:new int[]{-10,0,35,100,150}){VanillaLight.setSunshaftIntensity(n);forgetPreferences();check(VanillaLight.sunshaftIntensity()==Math.clamp(n,0,100),"Intensity clamps and persists");}VanillaLight.setSunshaftIntensity(35);}catch(java.io.IOException e){throw new AssertionError(e);}}
private static void preferences(){try{var p=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.writeString(p,"{\"bloom\":false,\"pixelShadows\":false}");forgetPreferences();check(VanillaLight.sunshafts()&&VanillaLight.sunshaftIntensity()==20&&!VanillaLight.bloom()&&!VanillaLight.pixelShadows(),"Missing shaft settings preserve existing choices");VanillaLight.setSunshafts(false);VanillaLight.setSunshaftIntensity(73);forgetPreferences();check(!VanillaLight.sunshafts()&&VanillaLight.sunshaftIntensity()==73,"Custom shafts persist");for(int n:new int[]{-10,0,35,100,150}){VanillaLight.setSunshaftIntensity(n);forgetPreferences();check(VanillaLight.sunshaftIntensity()==Math.clamp(n,0,100),"Intensity clamps and persists");}VanillaLight.setSunshaftIntensity(35);}catch(java.io.IOException e){throw new AssertionError(e);}}
}
@@ -0,0 +1,155 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
/** Tall stepped cliff: lit faces must not acquire horizontal self-shadow bars near noon. */
final class Zenith258ClientChecks {
static void run(ClientGameTestContext c, TestServerContext server) {
c.runOnClient(m -> {
VanillaLight.setProfile(ShaderProfile.HIGH);
VanillaLight.setSunshafts(false); VanillaLight.setLensFlare(false);
VanillaLight.setPbr(false); VanillaLight.setSsgi(false); VanillaLight.setSsr(false);
VanillaLight.setBloom(false); VanillaLight.setOreEmission(false); VanillaLight.setEdgeHighlights(false);
m.getWindow().setWindowed(1920,1080);
});
server.runOnServer(s -> {
for(int x=-28;x<=0;x++)for(int z=-24;z<=24;z++) {
int top=142+(z+24)/8*3+(x+28)/7;
for(int y=65;y<=top;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.CONCRETE.white().defaultBlockState());
}
// Exposed terraces, recesses and overhangs on a tall cliff, crossing several sections.
for(int z=-24;z<=24;z++)for(int y=80;y<=142;y++) {
int edge=(y>=118&&y<=122?5:y>=96&&y<=99?3:0)+(Math.floorMod(z,12)<4?2:0);
for(int x=1;x<=edge;x++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.CONCRETE.white().defaultBlockState());
}
});
camera(c,server,38,114,0,70); look(c,server,90,4);
c.waitFor(m -> m.levelRenderer.hasRenderedAllSections(),2400); c.waitTicks(60);
for(int time:new int[]{4000,4500,5000,5500,5750,5900,5999,6000,6001,6100,6250,6500,7000,7500,8000}) {
server.runOnServer(s -> {command(s,"time add 24000");command(s,"time set "+time);}); c.waitTicks(15);
cliffShadows(c,false); var off=capture(c,"zenith258-cliff-"+time+"-off");
cliffShadows(c,true); var on=capture(c,"zenith258-cliff-"+time+"-on");
c.waitTicks(5); unchanged(on,capture(c,null),"Frozen cliff "+time);
var points=points(c,off,125.125,40);
int dark=0,flips=0,lastColumn=Integer.MIN_VALUE;boolean last=false;double sum=0;
for(var point:points){
int delta=brightness(off.pixels()[point[0]])-brightness(on.pixels()[point[0]]);
boolean shaded=delta>8;
if(point[1]==lastColumn && shaded!=last)flips++;
lastColumn=point[1];last=shaded;if(shaded)dark++;sum+=delta;
}
check(points.size()>800,"Visible cliff faces cover a substantial receiving area");
check(flips<=82,"A flat cliff face must not contain repeated horizontal shadow bars: time="+time+" flips="+flips);
if(time<=5999)check(dark<points.size()/100+5,"Unoccluded sun-facing cliff stays lit: time="+time+" dark="+dark);
var below=points(c,off,110.125,40);int shadedBelow=0;
for(var point:below)if(brightness(off.pixels()[point[0]])-brightness(on.pixels()[point[0]])>8)shadedBelow++;
if(time>=5000&&time<=6001)check(shadedBelow>below.size()*.9,"True shadows below cliff overhangs are retained");
int draws=c.computeOnClient(m->PixelShadows.depthDraws());c.waitTicks(5);
c.runOnClient(m->{check(PixelShadows.depthDraws()==draws,"Fixed sun reuses its depth map");
check(!m.getMusicManager().isPlayingMusic(fr.koka.sanctuary.client.sound.MenuMusic.MENU),"Menu track is absent in the world");});
System.out.println("ZENITH258_CLIFF time="+time+" exposed="+points.size()+" dark="+dark+" flips="+flips+" underOverhang="+shadedBelow+"/"+below.size()+" meanDelta="+sum/points.size());
}
for(int time:new int[]{5500,6000,6500}) {
server.runOnServer(s->{command(s,"time add 24000");command(s,"time set "+time);});
camera(c,server,39.99999,114,0,70);look(c,server,90,4);cliffFrames(c);
var before=capture(c,null);
camera(c,server,40.00001,114,0,70);look(c,server,90,4);cliffFrames(c);
unchanged(before,capture(c,"zenith258-anchor-"+time),"Cliff remains stable across camera anchor at "+time);
}
realtime(c,server);
System.out.println("ZENITH258_PASS 10h-14h, tall cliff, terraces/overhangs, no bars, static cache and camera anchors");
}
private static void realtime(ClientGameTestContext c,TestServerContext server) {
var original=new java.util.concurrent.atomic.AtomicReference<java.time.Clock>();
var civil=new java.util.concurrent.atomic.AtomicReference<java.time.ZonedDateTime>();
var noon=new java.util.concurrent.atomic.AtomicReference<java.time.Instant>();
server.runOnServer(s->{
civil.set(fr.koka.sanctuary.realtime.RealtimeService.civilTime(s));
var session=fr.koka.sanctuary.realtime.RealtimeService.session(s);
noon.set(session.sample().day().noon());
original.set(clock(s,java.time.Clock.fixed(civil.get().withHour(10).withMinute(0).withSecond(0).withNano(0).toInstant(),civil.get().getZone())));
check(mode(s,true),"Actual realtime service enabled in the new test world");
});
try {
var instants=new java.util.ArrayList<java.time.Instant>();
for(int hour=10;hour<=14;hour++)instants.add(civil.get().withHour(hour).withMinute(0).withSecond(0).withNano(0).toInstant());
instants.add(noon.get().minusSeconds(30));instants.add(noon.get());instants.add(noon.get().plusSeconds(30));
int sample=0;var angles=new java.util.ArrayList<Float>();
for(var instant:instants) {
var phase=new java.util.concurrent.atomic.AtomicReference<Double>();
server.runOnServer(s->{
clock(s,java.time.Clock.fixed(instant,civil.get().getZone()));
fr.koka.sanctuary.realtime.RealtimeService.tick(s);
phase.set(fr.koka.sanctuary.realtime.RealtimeService.session(s).sample().phase());
// The test advances civil time without waiting for the periodic network packet.
s.getPlayerList().broadcastAll(s.clockManager().createFullSyncPacket());
});
c.waitFor(m->{
var definition=m.level.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.WORLD_CLOCK).getOrThrow(net.minecraft.world.clock.WorldClocks.OVERWORLD);
var clock=m.level.clockManager().getInstance(definition);
return Math.abs(Math.IEEEremainder(clock.totalTicks()+clock.partialTick()-phase.get(),24000))<2;
},150);
c.waitTicks(25);
cliffShadows(c,false);var off=capture(c,null);cliffShadows(c,true);var on=capture(c,"zenith258-realtime-"+sample++);
var points=points(c,off,125.125,40);int flips=0,lastColumn=Integer.MIN_VALUE;boolean last=false;
for(var point:points){boolean shaded=brightness(off.pixels()[point[0]])-brightness(on.pixels()[point[0]])>8;
if(point[1]==lastColumn&&shaded!=last)flips++;lastColumn=point[1];last=shaded;}
check(points.size()>800&&flips<=82,"Actual civil-time cliff has no repeated shadow bars: "+instant+" flips="+flips);
float angle=c.computeOnClient(m->m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);
angles.add(angle);
if(instant.equals(noon.get()))check(PixelShadows.sunDirection(angle).y>.9999,"Actual solar noon reaches the zenith");
System.out.println("ZENITH258_REALTIME angle="+angle+" civil="+instant.atZone(civil.get().getZone())+" solarNoon="+noon.get().atZone(civil.get().getZone())+" flips="+flips);
}
check(Math.abs(Math.IEEEremainder(angles.get(4)-angles.getFirst(),Math.PI*2))>.1,"Civil clock sweep actually moves the rendered sun");
} finally {
server.runOnServer(s->{mode(s,false);clock(s,original.get());});
}
}
private static void cliffShadows(ClientGameTestContext c,boolean enabled) {
if(!enabled){shadows(c,false);return;}
c.runOnClient(m->VanillaLight.setPixelShadows(true));cliffFrames(c);
}
private static void cliffFrames(ClientGameTestContext c) {
int before=c.computeOnClient(m->PixelShadows.renderedFrames());
try {
// A tall cliff spans more sections than the original single-pillar fixture.
c.waitFor(m->PixelShadows.applied()&&PixelShadows.ready()&&PixelShadows.renderedFrames()>before+12,800);
} catch(AssertionError error) {
c.runOnClient(m->System.out.println("ZENITH258_NOT_READY applied="+PixelShadows.applied()+" ready="+PixelShadows.ready()
+" frames="+(PixelShadows.renderedFrames()-before)+" sun="+m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle));
throw error;
}
c.waitTicks(30);
}
private static boolean mode(net.minecraft.server.MinecraftServer server,boolean realtime) {
try{return fr.koka.sanctuary.realtime.RealtimeService.setMode(server,realtime);}
catch(Exception e){throw new AssertionError(e);}
}
private static java.time.Clock clock(net.minecraft.server.MinecraftServer server,java.time.Clock next) {
try {
var session=fr.koka.sanctuary.realtime.RealtimeService.session(server);
var field=session.getClass().getDeclaredField("clock");field.setAccessible(true);
var previous=(java.time.Clock)field.get(session);field.set(session,next);return previous;
}catch(ReflectiveOperationException e){throw new AssertionError(e);}
}
private static java.util.List<int[]> points(ClientGameTestContext c,Frame frame,double minY,int height) {
return c.computeOnClient(m -> {
var state=m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
var vp=new org.joml.Matrix4f(state.projectionMatrix).mul(state.viewRotationMatrix);
var list=new java.util.ArrayList<int[]>();
for(int z=-20;z<=20;z++)for(int y=0;y<height;y++) {
double wz=z+.5,wy=minY+y*.125,wx=Math.floorMod(z,12)<4?3:1;
var point=vp.transform(new org.joml.Vector4f((float)(wx-state.pos.x),(float)(wy-state.pos.y),(float)(wz-state.pos.z),1));
int px=(int)((point.x/point.w*.5+.5)*frame.width()),py=(int)((.5-point.y/point.w*.5)*frame.height());
if(px>12&&py>12&&px<frame.width()-12&&py<frame.height()-12)list.add(new int[]{py*frame.width()+px,z});
}
return list;
});
}
}
@@ -0,0 +1,111 @@
package fr.koka.sanctuary.client.sound;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.client.IntroScreen;
import java.lang.reflect.Field;
import java.util.List;
import java.util.UUID;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.TitleScreen;
import net.minecraft.client.gui.screens.options.OptionsScreen;
import net.minecraft.client.resources.sounds.SoundInstance;
import net.minecraft.client.sounds.MusicManager;
import net.minecraft.client.sounds.SoundBufferLibrary;
import net.minecraft.network.chat.Component;
import net.minecraft.sounds.Music;
import net.minecraft.sounds.Musics;
import net.minecraft.sounds.SoundSource;
/** Test the real streamed file and music channel, including navigation and replay. */
public final class MenuMusic253ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
double volume = c.computeOnClient(m -> m.options.getSoundSourceOptionInstance(SoundSource.MUSIC).get());
c.runOnClient(m -> {
m.options.getSoundSourceOptionInstance(SoundSource.MUSIC).set(1D);
m.getSoundManager().updateCategoryVolume(SoundSource.MUSIC, 1F);
m.getMusicManager().stopPlaying();
m.gui.setScreen(new net.minecraft.client.gui.screens.GenericMessageScreen(Component.literal("Loading test")));
m.getSituationalMusic();
m.gui.setScreen(new TitleScreen(false));
var event = m.getSoundManager().getSoundEvent(MenuMusic.MENU.sound().value().location());
require(event != null && event.getWeight() == 1, "One menu music track resolves");
var buffers = new SoundBufferLibrary(m.getResourceManager());
try (var stream = buffers.getStream(SanctuaryMod.id("sounds/music/menu.ogg"), false).join()) {
require(stream.getFormat().getChannels() == 2 && stream.getFormat().getSampleRate() == 48000F, "Native Vorbis stereo decoder");
require(stream.read(4096).remaining() > 0, "Native decoder produces audio samples");
} catch (java.io.IOException e) { throw new AssertionError("OGG decode", e); }
});
c.waitTicks(30);
c.runOnClient(m -> require(!m.getMusicManager().isPlayingMusic(MenuMusic.MENU), "Title stays silent for the first seconds"));
c.waitFor(m -> current(m.getMusicManager()) != null && m.getSoundManager().isActive(current(m.getMusicManager())), 120);
var playing = c.computeOnClient(m -> current(m.getMusicManager()));
try {
c.runOnClient(m -> {
require(m.getMusicManager().isPlayingMusic(MenuMusic.MENU), "Supplied track starts after the title delay");
require(playing.getSource() == SoundSource.MUSIC && playing.getSound().shouldStream(), "Native music volume and streaming");
require(playing.getSound().getLocation().equals(SanctuaryMod.id("music/menu")), "Supplied asset is played");
m.gui.setScreen(new OptionsScreen(m.gui.screen(), m.options));
});
c.waitTicks(10);
c.runOnClient(m -> require(current(m.getMusicManager()) == playing && m.getSoundManager().isActive(playing), "Options retain the same stream"));
c.runOnClient(m -> m.gui.screen().onClose());
c.waitTicks(5);
c.runOnClient(m -> require(current(m.getMusicManager()) == playing, "Returning to title does not restart the track"));
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();
c.waitFor(m -> m.getMusicManager().isPlayingMusic(MenuMusic.MENU) && current(m.getMusicManager()) != null
&& m.getSoundManager().isActive(current(m.getMusicManager())), 120);
c.runOnClient(m -> {
String key = m.getMusicManager().getCurrentMusicTranslationKey();
require(Component.translatable(key).getString().equals("Sanctuary — Menu"), "Music title translated in " + locale + ": " + key);
});
}
var beforeEnd = c.computeOnClient(m -> current(m.getMusicManager()));
c.runOnClient(m -> m.getSoundManager().stop(beforeEnd));
c.waitFor(m -> current(m.getMusicManager()) != null && current(m.getMusicManager()) != beforeEnd
&& m.getSoundManager().isActive(current(m.getMusicManager())), 120);
c.runOnClient(m -> m.gui.setScreen(new fr.koka.sanctuary.client.HelloWorldScreen(
new fr.koka.sanctuary.progression.ProgressionPayloads.Hello("Music258", List.of(0), ""), () -> {})));
c.waitTicks(5);
c.runOnClient(m -> require(!m.getMusicManager().isPlayingMusic(MenuMusic.MENU), "Hello World stops menu music"));
c.waitTicks(80);
c.runOnClient(m -> require(!m.getMusicManager().isPlayingMusic(MenuMusic.MENU), "Hello World cannot restart menu music"));
c.runOnClient(m -> {
m.gui.setScreen(new IntroScreen(UUID.randomUUID(), skipped -> {}));
require(!m.getMusicManager().isPlayingMusic(MenuMusic.MENU), "Portal introduction stops menu music");
});
c.waitTicks(5);
c.runOnClient(m -> {
require(!m.getMusicManager().isPlayingMusic(MenuMusic.MENU), "Menu soundtrack cannot restart during the introduction");
m.gui.setScreen(new Screen(Component.literal("Explicit music regression")) {
@Override public Music getBackgroundMusic() { return Musics.GAME; }
});
require(m.getSituationalMusic() == Musics.GAME, "Explicit screen music remains authoritative");
});
System.out.println("MENU253_CLIENT_PASS: native stereo Vorbis stream, delayed title music, continuous Options navigation, FR/EN title, resource reload, replay Hello World and introduction isolation");
} finally {
c.runOnClient(m -> {
m.getMusicManager().stopPlaying();
m.options.getSoundSourceOptionInstance(SoundSource.MUSIC).set(volume);
m.getSoundManager().updateCategoryVolume(SoundSource.MUSIC, (float) volume);
m.gui.setScreen(new TitleScreen(false));
});
}
}
private static SoundInstance current(MusicManager manager) {
try {
Field field = MusicManager.class.getDeclaredField("currentMusic");
field.setAccessible(true);
return (SoundInstance) field.get(manager);
} catch (ReflectiveOperationException e) { throw new AssertionError("Native music channel", e); }
}
private static void require(boolean value, String message) { if (!value) throw new AssertionError(message); }
}
@@ -0,0 +1,135 @@
package fr.koka.sanctuary.client.sound;
import com.mojang.blaze3d.platform.InputConstants;
import com.sonicether.soundphysics.SoundPhysicsMod;
import com.sonicether.soundphysics.config.SoundPhysicsConfig;
import de.maxhenkel.sound_physics_remastered.configbuilder.ConfigBuilder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
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.gui.components.AbstractWidget;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.CycleButton;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.events.ContainerEventHandler;
import net.minecraft.client.gui.components.events.GuiEventListener;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.TitleScreen;
import net.minecraft.client.gui.screens.options.OptionsScreen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.Component;
/** Native configuration persistence and menus; no player world is modified. */
public final class SoundPhysics252ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
checkDefaultsAndExistingFile();
boolean enabled = c.computeOnClient(m -> SoundPhysicsMod.CONFIG.enabled.get());
float gain = c.computeOnClient(m -> SoundPhysicsMod.CONFIG.reverbGain.get());
try {
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 (int scale : List.of(2, 4)) {
c.runOnClient(m -> {
m.options.guiScale().set(scale); m.resizeGui();
m.gui.setScreen(new OptionsScreen(new TitleScreen(false), m.options));
var sound = button(m.gui.screen(), "sanctuary.sound_physics.title");
require(sound.getX() >= 0 && sound.getX() + sound.getWidth() <= m.gui.screen().width, "Options button fits horizontally");
require(sound.getY() >= 0 && sound.getY() + sound.getHeight() <= m.gui.screen().height - 25, "Options button is reachable at scale " + scale);
sound.onPress(enter());
require(m.gui.screen() instanceof SoundPhysicsOptionsScreen, "Native Sound Physics menu opens");
for (String category : List.of("general", "performance", "reflectivity", "occlusion", "voice", "debug")) {
button(m.gui.screen(), "sanctuary.sound_physics." + category).onPress(enter());
require(widgets(m.gui.screen()).size() > 1, "Settings category is populated: " + category);
for (var widget : widgets(m.gui.screen())) {
require(!widget.getMessage().getString().contains("sanctuary.sound_physics."), "Translated label " + locale);
}
m.gui.screen().onClose();
}
System.out.println("SOUND252_LAYOUT " + locale + " scale=" + scale);
});
c.waitTicks(5);
}
}
c.runOnClient(m -> {
m.options.guiScale().set(2); m.resizeGui();
var toggle = widgets(m.gui.screen()).stream().filter(CycleButton.class::isInstance).findFirst().orElseThrow();
toggle.keyPressed(enter());
require(SoundPhysicsMod.CONFIG.enabled.get() != enabled, "Enabled state changes immediately");
require(readSavedConfig().enabled.get() != enabled, "Enabled state survives reload from disk");
button(m.gui.screen(), "sanctuary.sound_physics.general").onPress(enter());
var name = SoundPhysicsOptionsScreen.label("reverb_gain").getString();
widgets(m.gui.screen()).stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getMessage().getString().startsWith(name)).findFirst().orElseThrow().onPress(enter());
require(m.gui.screen() instanceof SoundPhysicsValueScreen, "Numeric editor opens");
var input = widgets(m.gui.screen()).stream().filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow();
for (String invalid : List.of("", "NaN", "Infinity", "0.09", "2.01", "hello")) {
input.setValue(invalid);
require(!button(m.gui.screen(), "gui.done").active, "Invalid input is rejected: " + invalid);
require(SoundPhysicsMod.CONFIG.reverbGain.get() == gain, "Invalid input cannot mutate config");
}
input.setValue("1,25");
button(m.gui.screen(), "gui.done").onPress(enter());
require(SoundPhysicsMod.CONFIG.reverbGain.get() == 1.25F, "French decimal input accepted");
require(readSavedConfig().reverbGain.get() == 1.25F, "Numeric preference survives disk reload");
widgets(m.gui.screen()).stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getMessage().getString().startsWith(name)).findFirst().orElseThrow().onPress(enter());
input = widgets(m.gui.screen()).stream().filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow();
input.setValue("1.75"); m.gui.screen().onClose();
require(SoundPhysicsMod.CONFIG.reverbGain.get() == 1.25F, "Cancel leaves the saved value intact");
m.gui.screen().onClose();
});
c.takeScreenshot("sound252-options");
System.out.println("SOUND252_CLIENT_PASS: fresh default off, existing preferences retained, native FR/EN menus, live toggle, save/reload and numeric validation");
} finally {
c.runOnClient(m -> {
SoundPhysicsMod.CONFIG.enabled.set(enabled).saveSync();
SoundPhysicsMod.CONFIG.reverbGain.set(gain).saveSync();
SoundPhysicsMod.CONFIG.reloadClient(); m.getSoundManager().reload();
m.gui.setScreen(new TitleScreen(false));
});
}
}
private static void checkDefaultsAndExistingFile() {
try {
Path path = Files.createTempDirectory("sanctuary-sound252-").resolve("soundphysics.properties");
var fresh = ConfigBuilder.builder(SoundPhysicsConfig::new).path(path).build();
require(!fresh.enabled.get() && !fresh.enabled.getDefault(), "Fresh config defaults to disabled");
fresh.enabled.set(true).saveSync(); fresh.reverbGain.set(1.3F).saveSync();
var existing = ConfigBuilder.builder(SoundPhysicsConfig::new).path(path).build();
require(existing.enabled.get() && existing.reverbGain.get() == 1.3F, "Existing enabled and numeric values are preserved");
try (var files = Files.walk(path.getParent())) {
for (var file : files.sorted(java.util.Comparator.reverseOrder()).toList()) Files.delete(file);
}
} catch (Exception e) { throw new AssertionError("Config compatibility", e); }
}
private static SoundPhysicsConfig readSavedConfig() {
return ConfigBuilder.builder(SoundPhysicsConfig::new).path(FabricLoader.getInstance().getConfigDir()
.resolve("sound_physics_remastered/soundphysics.properties")).build();
}
private static List<AbstractWidget> widgets(GuiEventListener root) {
List<AbstractWidget> result = new ArrayList<>();
if (root instanceof AbstractWidget widget) result.add(widget);
if (root instanceof ContainerEventHandler container) for (var child : container.children()) result.addAll(widgets(child));
return result;
}
private static Button button(Screen screen, String key) {
String label = Component.translatable(key).getString();
return widgets(screen).stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getMessage().getString().equals(label)).findFirst().orElseThrow(() -> new AssertionError("Missing button " + key));
}
private static KeyEvent enter() { return new KeyEvent(InputConstants.KEY_RETURN, 0, 0); }
private static void require(boolean value, String message) { if (!value) throw new AssertionError(message); }
}
@@ -61,6 +61,9 @@ public final class Fog053ClientChecks implements FabricClientGameTest {
c.runOnClient(m->{
m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
VanillaLight.setSaturation(100);m.gui.setScreen(new fr.koka.sanctuary.client.shader.ShaderOptionsScreen(null));
widgets(m.gui.screen()).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.shaders.page.camera"))
.findFirst().orElseThrow().onPress(enter());
var slider=widgets(m.gui.screen()).filter(AbstractSliderButton.class::isInstance).map(AbstractSliderButton.class::cast).findFirst().orElseThrow();
require(slider.getMessage().getString().contains("100")&&slider.getMessage().getString().contains("Saturation"),"Localized native saturation slider");
slider.keyPressed(enter());for(int i=0;i<8;i++)slider.keyPressed(new KeyEvent(InputConstants.KEY_RIGHT,0,0));
@@ -48,9 +48,9 @@ public final class Progression003ClientChecks implements FabricClientGameTest {
context.runOnClient(client -> {
var colorButtons=client.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getClass().getSimpleName().equals("ColorButton")).toList();
require(colorButtons.size()==20 && colorButtons.stream().allMatch(b -> b.getMessage().getContents()
require(colorButtons.size()==9 && colorButtons.stream().allMatch(b -> b.getMessage().getContents()
instanceof net.minecraft.network.chat.contents.TranslatableContents t && t.getKey().startsWith("sanctuary.color.")),
"All twenty native colour buttons display translated hue names");
"A hotbar page of nine native colour swatches exposes translated hue names");
});
context.takeScreenshot("sanctuary-beta005-couleurs-fr");
Blocodex004ClientChecks.language(context,initialLanguage);
@@ -35,8 +35,8 @@ public final class Starter052ClientChecks implements FabricClientGameTest {
require(name(screen).getValue().equals(NAME)&&screen.starterChoices().equals(choices),"Rejected colour preserves name and original eggs");
screen.accept(welcome);
var slots=screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("StarterSlot")).toList();
require(slots.getFirst().getX()==name(screen).getX()&&slots.getFirst().getX()==color(screen).getX(),"Egg choices align with the name field and colour controls on the left");
require(name(screen).getY()>slots.getFirst().getBottom()&&button(screen,"sanctuary.progression.enter").getY()>name(screen).getBottom(),"Name beneath eggs and above actions");
require(slots.getFirst().getX()>=name(screen).getX()&&slots.getLast().getRight()<=name(screen).getRight(),"Egg choices occupy the central familiar section");
require(name(screen).getY()>color(screen).getBottom()&&button(screen,"sanctuary.progression.enter").getY()>Math.max(name(screen).getBottom(),slots.getFirst().getBottom()),"Character above familiar name, actions below both panels");
for(var child:screen.children())if(child instanceof AbstractWidget w)require(w.getX()>=0&&w.getRight()<=screen.width&&w.getY()>=0&&w.getBottom()<=screen.height,"Widget fits "+locale+" "+screen.width+"x"+screen.height);
require(!name(screen).getMessage().getString().startsWith("sanctuary."),"Localized name field");
System.out.println("STARTER052_LAYOUT "+locale+" gui="+screen.width+"x"+screen.height);
@@ -7,6 +7,12 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
abstract class ClientVsync046Mixin {
@Inject(method="renderFrame",at=@At("HEAD"))
private void sanctuary$testPresentation(CallbackInfo ci){
if(Boolean.getBoolean("sanctuary.test.windowed")){
var m=(Minecraft)(Object)this;
if(m.options.fullscreen().get())m.options.fullscreen().set(false);
if(m.options.exclusiveFullscreen().get())m.options.exclusiveFullscreen().set(false);
m.getWindow().setFullscreen(false);
}
if(Boolean.getBoolean("sanctuary.test.noVsync")){
var m=(Minecraft)(Object)this;if(m.options.enableVsync().get())m.options.enableVsync().set(false);
}
@@ -0,0 +1,129 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.recipes.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.atlas.AtlasService;
import java.util.UUID;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.*;
import net.minecraft.util.ProblemReporter;
import net.minecraft.network.chat.Component;
/** Behavioral checks on real server players, native menus and vanilla persistence. */
public final class Aptitudes236GameTests {
@GameTest public void preferencesPreservePurchasesXpDeathAndCycle(GameTestHelper h) {
var record=ProgressRecord.INITIAL;
for(String skill:ProgressRecord.SKILLS)while(record.rank(skill)<ProgressRecord.cap(skill))record=record.buy(skill,record.cost(skill,ProgressionConfig.DEFAULT));
for(String aptitude:ProgressRecord.APTITUDES)record=record.buy(aptitude,4);
var p=player(h,record);p.setExperienceLevels(20);
p.setAttached(CompanionService.RECORD,CompanionRecord.EMPTY.purchase(4,1).json());
p.setAttached(CompanionStudy.RECEIPT,"{\"schema\":1,\"paid\":4,\"at\":1}");
p.setAttached(ZoomService.LEARNED,true);
h.assertTrue(RecipeService.buy(p),"Catalogue acquired through its normal server purchase");
h.assertTrue(CraftAptitudes.buy(p,"enchanting",record.revision()) && CraftAptitudes.buy(p,"alchemy",record.revision()),"New craft purchases accepted");
String proof=p.getAttachedOrThrow(ProgressionService.RECORD),recipes=p.getAttached(RecipeService.RECORD),bond=p.getAttached(CompanionService.RECORD);
int xp=p.experienceLevel;
h.assertTrue(!p.hasAttached(AptitudeService.PREFERENCES),"Old players need no preference migration");
for(String target:AptitudePreferences.TOGGLEABLE)h.assertTrue(toggle(p,target,false),"Owned aptitude can be disabled: "+target);
h.assertTrue(AptitudeService.preferences(p).disabledMask()==AptitudePreferences.ACTIVE_MASK && p.experienceLevel==xp,"Both display choices are independent and free");
h.assertTrue(proof.equals(p.getAttached(ProgressionService.RECORD)) && recipes.equals(p.getAttached(RecipeService.RECORD)) && bond.equals(p.getAttached(CompanionService.RECORD)),"Purchase proofs remain byte-for-byte unchanged");
var output=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());p.saveWithoutId(output);
var loaded=Progression003GameTests.player(h);loaded.load(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),output.buildResult()));
h.assertTrue(AptitudeService.preferences(loaded).equals(AptitudeService.preferences(p)) && CraftAptitudes.learned(loaded,"enchanting") && CraftAptitudes.learned(loaded,"alchemy"),"Choices and craft purchases reload from vanilla player NBT");
var respawn=Progression003GameTests.player(h);respawn.setUUID(p.getUUID());respawn.restoreFrom(p,false);
ServerPlayerEvents.AFTER_RESPAWN.invoker().afterRespawn(p,respawn,false);
h.assertTrue(AptitudeService.preferences(respawn).equals(AptitudeService.preferences(p)) && CraftAptitudes.learned(respawn,"alchemy"),"Choices and new purchases survive ordinary death");
bind(p,record.nextCycle());
h.assertTrue(AptitudeService.preferences(p).disabledMask()==AptitudePreferences.ACTIVE_MASK && CraftAptitudes.learned(p,"enchanting"),"Skill reset preserves separate preferences and craft purchases");
for(String target:AptitudePreferences.TOGGLEABLE)h.assertTrue(toggle(p,target,true),"Reactivation needs no new purchase: "+target);
h.assertTrue(p.experienceLevel==xp && AptitudeService.preferences(p).equals(AptitudePreferences.DEFAULT),"Reactivation costs no XP");h.succeed();
}
@GameTest public void unknownUnownedPermanentAndStaleRequestsRefused(GameTestHelper h) {
var p=player(h,ProgressRecord.INITIAL.buy("names",4));p.setExperienceLevels(10);
for(String target:new String[]{"swimming","enchanting","alchemy","food_knowledge","unknown","vitality"})
h.assertTrue(!toggle(p,target,false),"Cannot toggle unowned, permanent or non-aptitude target: "+target);
h.assertTrue(toggle(p,"names",false),"Owned name reveal disables");
h.assertTrue(!AptitudeService.set(p,new AptitudeService.Set("names",true,0)),"Stale mask cannot overwrite a newer choice");
h.assertTrue(!CraftAptitudes.buy(p,"enchanting",99) && !CraftAptitudes.buy(p,"unknown",1),"Stale and unknown purchases refused");
p.setExperienceLevels(3);h.assertTrue(!CraftAptitudes.buy(p,"alchemy",1) && p.experienceLevel==3,"Insufficient XP never spent");
p.setExperienceLevels(4);h.assertTrue(CraftAptitudes.buy(p,"alchemy",1) && p.experienceLevel==0 && !CraftAptitudes.buy(p,"alchemy",1),"Four levels buy once");
h.assertTrue(!AptitudeService.enabled(p,"names"),"Refusal preserves previous choice");h.succeed();
}
@GameTest public void legacyPreferencesRestorePermanentPurchases(GameTestHelper h) {
var p=player(h,ProgressRecord.INITIAL.buy("names",4).buy("atlas",4));p.setExperienceLevels(12);
h.assertTrue(CraftAptitudes.buy(p,"enchanting",2) && CraftAptitudes.buy(p,"alchemy",2),"Craft purchases exist before legacy migration");
p.setAttached(AptitudeService.PREFERENCES,AptitudePreferences.MAX_MASK);
String proof=p.getAttached(ProgressionService.RECORD);int xp=p.experienceLevel;
var output=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());p.saveWithoutId(output);
var loaded=Progression003GameTests.player(h);loaded.load(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),output.buildResult()));
h.assertTrue(loaded.getAttachedOrThrow(AptitudeService.PREFERENCES)==2047,"Historical codec still reloads all eleven bits");
h.assertTrue(AptitudeService.preferences(loaded).disabledMask()==3 && !AptitudeService.enabled(loaded,"names") && !AptitudeService.enabled(loaded,"atlas"),"Legacy display choices retain their original bit positions");
for(String target:AptitudePreferences.PERMANENT) {
h.assertTrue(AptitudeService.enabled(loaded,target),"Legacy permanent preference has no effect: "+target);
h.assertTrue(!toggle(p,target,false),"Permanent aptitude cannot be disabled: "+target);
}
AptitudeService.send(loaded);
h.assertTrue(loaded.getAttachedOrThrow(AptitudeService.PREFERENCES)==3 && loaded.experienceLevel==xp && proof.equals(loaded.getAttached(ProgressionService.RECORD)),"Sync normalizes only preferences, preserving XP and purchase proofs");
h.assertTrue(CraftAptitudes.learned(loaded,"enchanting") && CraftAptitudes.learned(loaded,"alchemy"),"Migration retains both craft purchases");h.succeed();
}
@GameTest public void permanentServerEffectsIgnoreLegacyDisabledBits(GameTestHelper h) {
var r=ProgressRecord.INITIAL.buy("hunger",1).buy("swimming",4).buy("resting",4).buy("sorting",4).buy("atlas",4);
var p=player(h,r);p.getFoodData().setFoodLevel(8);p.setAttached(AptitudeService.PREFERENCES,2044);
var at=p.blockPosition();for(int x=-1;x<=1;x++)for(int z=-1;z<=1;z++)for(int y=0;y<=3;y++)h.getLevel().setBlockAndUpdate(at.offset(x,y,z),Blocks.WATER.defaultBlockState());
p.baseTick();p.baseTick();p.setSprinting(true);p.setSwimming(true);
h.assertTrue(p.isSwimming() && !toggle(p,"swimming",false) && p.isSwimming() && ProgressEffects.canSwim(p),"Swimming stays usable with an old disabled bit and a rejected request");
for(int x=-1;x<=1;x++)for(int z=-1;z<=1;z++)for(int y=0;y<=3;y++)h.getLevel().setBlockAndUpdate(at.offset(x,y,z),Blocks.AIR.defaultBlockState());
p.baseTick();p.baseTick();p.setOnGround(true);
h.assertTrue(LyingService.request(p,new LyingService.Request(2,r.revision())),"Purchased Rest ignores its legacy disabled bit");
h.assertTrue(!toggle(p,"resting",false) && LyingService.posture(p)==2,"Refused disable request never stops Rest");
h.assertTrue(InventorySortService.sort(p,new InventorySortPayloads.Sort(1,p.containerMenu.containerId,p.containerMenu.getStateId(),r.revision(),InventorySortPayloads.HOTBAR)),"Purchased inventory sorting ignores its legacy disabled bit");
h.assertTrue(AtlasService.canRead(p,false) && toggle(p,"atlas",false) && !AtlasService.canRead(p,false),"Personal World Map access follows the display choice");
h.assertTrue(toggle(p,"atlas",true) && AtlasService.canRead(p,false),"World Map reactivates with its existing purchase");
LyingService.request(p,new LyingService.Request(0,r.revision()));
p.setAttached(CompanionService.RECORD,CompanionRecord.EMPTY.purchase(4,1).json());
h.assertTrue(!toggle(p,"active_bond",false) && CompanionService.activate(p,-1).equals("no_familiar"),"Active Bond reaches the normal familiar requirement despite its legacy disabled bit");h.succeed();
}
@GameTest public void enchantingNativeMenuRequiresPermanentPurchase(GameTestHelper h) {
var p=player(h,ProgressRecord.INITIAL);p.setAttached(AptitudeService.PREFERENCES,2044);var at=p.blockPosition();h.getLevel().setBlockAndUpdate(at,Blocks.ENCHANTING_TABLE.defaultBlockState());
var provider=new SimpleMenuProvider((id,inventory,player)->new EnchantmentMenu(id,inventory,ContainerLevelAccess.create(h.getLevel(),at)),Component.literal("Enchanting"));
h.assertTrue(p.openMenu(provider).isEmpty() && p.containerMenu==p.inventoryMenu,"Unlearned player cannot open a native enchanting menu");
p.setExperienceLevels(40);h.assertTrue(CraftAptitudes.buy(p,"enchanting",0) && p.experienceLevel==36,"Unlock charges exactly four levels");
h.assertTrue(p.openMenu(provider).isPresent(),"Native menu opens after purchase");var menu=(EnchantmentMenu)p.containerMenu;
menu.getSlot(0).set(new ItemStack(Items.DIAMOND_SWORD));menu.getSlot(1).set(new ItemStack(Items.LAPIS_LAZULI,3));
h.assertTrue(menu.clickMenuButton(p,0) && !menu.getSlot(0).getItem().get(DataComponents.ENCHANTMENTS).isEmpty(),"Real vanilla enchanting remains functional");
int xp=p.experienceLevel;
h.assertTrue(!toggle(p,"enchanting",false) && p.containerMenu==menu && menu.stillValid(p) && p.experienceLevel==xp,"Purchased enchanting cannot be disabled or closed by a toggle request");
p.closeContainer();h.assertTrue(p.openMenu(provider).isPresent(),"Enchanting remains available on later visits");p.closeContainer();h.succeed();
}
@GameTest public void alchemyNativeMenuRetainsSharedInventory(GameTestHelper h) {
var p=player(h,ProgressRecord.INITIAL);p.setAttached(AptitudeService.PREFERENCES,2044);var at=p.blockPosition();h.getLevel().setBlockAndUpdate(at,Blocks.BREWING_STAND.defaultBlockState());
var stand=(net.minecraft.world.level.block.entity.BrewingStandBlockEntity)h.getLevel().getBlockEntity(at);
h.assertTrue(p.openMenu(stand).isEmpty(),"Unlearned player cannot open brewing stand");
p.setExperienceLevels(4);h.assertTrue(CraftAptitudes.buy(p,"alchemy",0) && p.openMenu(stand).isPresent(),"Alchemy unlock opens the native stand");
stand.setItem(3,new ItemStack(Items.NETHER_WART));stand.setItem(4,new ItemStack(Items.BLAZE_POWDER));
var menu=p.containerMenu;h.assertTrue(!toggle(p,"alchemy",false) && p.containerMenu==menu && menu.stillValid(p),"Alchemy cannot be disabled or closed by a toggle request");
h.assertTrue(stand.getItem(3).is(Items.NETHER_WART) && stand.getItem(4).is(Items.BLAZE_POWDER),"Shared ingredients are never removed");
p.closeContainer();h.assertTrue(p.openMenu(stand).isPresent(),"Purchased Alchemy stays available on later visits");
p.closeContainer();var vanilla=Progression003GameTests.player(h);
h.assertTrue(CraftAptitudes.allowed(vanilla,"enchanting") && CraftAptitudes.allowed(vanilla,"alchemy"),"Inactive vanilla players retain native access");h.succeed();
}
private static boolean toggle(ServerPlayer p,String target,boolean enabled) {return AptitudeService.set(p,new AptitudeService.Set(target,enabled,AptitudeService.preferences(p).disabledMask()));}
private static ServerPlayer player(GameTestHelper h,ProgressRecord record) {
var p=Progression003GameTests.player(h);p.setUUID(UUID.randomUUID());
try {var identities=ProgressionService.session(h.getLevel().getServer()).inhabitants;identities.create(p.getUUID(),"apt"+p.getUUID().toString().substring(0,8),"",identities.available().getFirst());}
catch(Exception e){throw new AssertionError(e);}
bind(p,record);return p;
}
private static void bind(ServerPlayer p,ProgressRecord record){p.setAttached(ProgressionService.RECORD,record.json());ProgressEffects.apply(p,record);}
}
@@ -0,0 +1,59 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.atlas.*;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.GameType;
public final class Atlas244GameTests {
@GameTest public void deliveredChunksAreCompleteAndPersonalBeforePurchase(GameTestHelper h) throws Exception {
var player=inhabitant(h,GameType.CREATIVE);
var origin=h.absolutePos(BlockPos.ZERO);
var chunk=h.getLevel().getChunkSource().getChunkNow(origin.getX()>>4,origin.getZ()>>4);
h.assertTrue(chunk!=null,"Fixture is FULL");
player.setPos(origin.getX()-160,origin.getY(),origin.getZ()-160);
AtlasService.observeSentChunk(player,chunk);
for (int altitude:AtlasAltitude.LEVELS) {
var store=AtlasService.observations(player,altitude);var tile=store.get(chunk.getPos().x(),chunk.getPos().z());
h.assertTrue(tile!=null && Arrays.equals(tile.colors(),AtlasSampler.sample(h.getLevel(),tile.x(),tile.z(),altitude).colors()),
"Delivered chunk is complete at every altitude, beyond the old 32-block radius");
for(byte color:tile.colors())h.assertTrue(color!=0,"No unobserved pixels in a delivered chunk, including void");
store.save();
}
h.assertTrue(!ProgressEffects.get(player).atlas(),"Discovery still starts before aptitude purchase");
var other=inhabitant(h,GameType.CREATIVE);
h.assertTrue(AtlasService.observations(other).size()==0,"Other inhabitants do not inherit delivered terrain");
AtlasService.observeSentChunk(player,chunk);
h.assertTrue(!AtlasService.observations(player).dirty(),"Identical delivery does not rewrite the atlas");
var chunks=fr.koka.sanctuary.knowledge.census.LoadedChunks.full(h.getLevel()).stream().limit(64).toList();
for (var loaded:chunks) AtlasSampler.sampleLayers(loaded);
long[] timings=new long[chunks.size()];
for (int i=0;i<chunks.size();i++){long t=System.nanoTime();AtlasSampler.sampleLayers(chunks.get(i));timings[i]=System.nanoTime()-t;}
Arrays.sort(timings);
System.out.printf(Locale.ROOT,"MAP244_NATIVE_SAMPLE_CPU: %s, %d chunks, mean %.3f ms, p95 %.3f ms, six layers%n",
h.getLevel().getChunkSource().getGenerator().getClass().getSimpleName(),timings.length,
Arrays.stream(timings).average().orElseThrow()/1e6,timings[(timings.length-1)*95/100]/1e6);
h.succeed();
}
@GameTest public void spectatorsAndAbsentChunksRemainUnobserved(GameTestHelper h) {
var origin=h.absolutePos(BlockPos.ZERO);
var chunk=h.getLevel().getChunkSource().getChunkNow(origin.getX()>>4,origin.getZ()>>4);
var spectator=inhabitant(h,GameType.SPECTATOR);
AtlasService.observeSentChunk(spectator,chunk);
h.assertTrue(AtlasService.observations(spectator).size()==0,"Spectator deliveries do not expand personal discovery");
int far=1000000;
h.assertTrue(h.getLevel().getChunkSource().getChunkNow(far,far)==null,"Remote terrain starts unloaded");
h.assertTrue(AtlasSampler.sampleLayers(h.getLevel(),far,far).isEmpty()
&& h.getLevel().getChunkSource().getChunkNow(far,far)==null,"No chunk ticket or terrain generation for the atlas");
h.succeed();
}
private static ServerPlayer inhabitant(GameTestHelper h,GameType mode) {
var p=(ServerPlayer)h.makeMockServerPlayer(mode);
p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);return p;
}
}
@@ -31,6 +31,9 @@ public final class Familiar020GameTests {
var stored=AccessoryAccess.of(loaded).getItem(0);
h.assertTrue(ItemStack.isSameItemSameComponents(egg,stored),"Native inventory save/load preserves the exact egg and size");
var other=player(h);stored.set(DataComponents.CUSTOM_NAME,Component.literal("Géant de poche"));
// Transfer the saved individual, rather than equipping a concurrent copy held by the first owner.
AccessoryAccess.of(p).setItem(0,ItemStack.EMPTY);FamiliarService.tick(p);fr.koka.sanctuary.familiar.FamiliarBattle.tick(p);
AccessoryAccess.of(loaded).setItem(0,ItemStack.EMPTY);FamiliarService.tick(loaded);fr.koka.sanctuary.familiar.FamiliarBattle.tick(loaded);
AccessoryAccess.of(other).setItem(0,stored);other.tickCount+=10;FamiliarService.tick(other);
h.assertTrue(FamiliarSize.value(FamiliarService.active(other).egg())==size,"Name changes and transfer to another inhabitant keep the same size");h.succeed();
}
@@ -0,0 +1,103 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.grave.GraveService;
import fr.koka.sanctuary.inventory.*;
import java.util.UUID;
import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.entity.SkullBlockEntity;
import net.minecraft.world.level.gamerules.GameRules;
/** A respawn retains the native entity ID, but starts a new player clock. */
public final class Familiar260GameTests {
@GameTest public void keepInventoryRespawnAcceptsOrdersImmediately(GameTestHelper h){
var p=longLife(h);var pet=FamiliarService.active(p);var egg=FamiliarBattle.egg(p).copy();
var bond=FamiliarBattle.bond(p).health(4,0).cooldown(FamiliarBattle.now()+60000);
FamiliarBattle.update(p,bond);FamiliarBattle.combat(p);
long combat=p.getAttachedOrElse(FamiliarBattle.COMBAT_LOCK,0L);
var next=dieAndRespawn(h,p,true);
h.assertTrue(ItemStack.isSameItemSameComponents(egg,FamiliarBattle.egg(next)),"Keep inventory preserves the exact individual egg");
commands(h,next);
h.assertTrue(pet.isRemoved(),"The old life's visual familiar is retired even before its next entity tick");
FamiliarService.tick(next);var fresh=FamiliarService.active(next);
h.assertTrue(fresh!=null&&fresh!=pet&&fresh.owner()==next,"The familiar binds to the actual new player object");
h.assertTrue(FamiliarBattle.bond(next).id().equals(bond.id())&&fresh.getHealth()==4
&&FamiliarBattle.bond(next).readyAt()==bond.readyAt()
&&next.getAttachedOrElse(FamiliarBattle.COMBAT_LOCK,0L)==combat,"Respawn keeps identity, damage, technique cooldown and combat lock");
cleanup(next);h.succeed();
}
@GameTest public void recoveredGraveEggCanBeEquippedAndCommanded(GameTestHelper h){
var p=longLife(h);var egg=FamiliarBattle.egg(p).copy();var at=p.blockPosition();
var next=dieAndRespawn(h,p,false);
h.assertTrue(FamiliarBattle.egg(next).isEmpty(),"Ordinary death does not retain or duplicate the equipped egg");
h.assertTrue(h.getLevel().getBlockEntity(at) instanceof SkullBlockEntity,"Actual death creates a grave");
var grave=GraveService.container((SkullBlockEntity)h.getLevel().getBlockEntity(at));
ItemStack recovered=ItemStack.EMPTY;
for(int i=0;i<grave.getContainerSize();i++)if(FamiliarIdentity.get(grave.getItem(i))!=null){recovered=grave.removeItem(i,1);break;}
h.assertTrue(ItemStack.isSameItemSameComponents(egg,recovered),"Native grave extraction returns the original individual");
AccessoryAccess.of(next).setItem(AccessoryContainer.FAMILIAR,recovered);
FamiliarService.tick(next);commands(h,next);
h.assertTrue(FamiliarService.active(next)!=null&&FamiliarService.active(next).owner()==next,"Recovered egg spawns a usable familiar");
cleanup(next);h.succeed();
}
@GameTest public void deathReleasesClaimWithoutAllowingConcurrentDuplicates(GameTestHelper h){
var p=longLife(h);var egg=FamiliarBattle.egg(p).copy();UUID id=FamiliarIdentity.get(egg);
var other=Companion019GameTests.player(h,CompanionType.CAT);
AccessoryAccess.of(other).setItem(AccessoryContainer.FAMILIAR,egg.copy());FamiliarService.tick(other);
h.assertTrue(FamiliarService.active(other)==null,"A live owner's duplicate egg cannot acquire the same individual");
var next=dieAndRespawn(h,p,false);
FamiliarService.tick(other);
h.assertTrue(FamiliarService.active(other)!=null&&FamiliarBattle.bond(other).id().equals(id),"Death releases a claim even if no dead-player tick ran");
h.assertTrue(FamiliarBattle.bond(other).controller().equals(other.getUUID()),"The next real holder becomes controller");
cleanup(other);cleanup(next);h.succeed();
}
@GameTest public void respawnDoesNotReviveKnockedOutOrRecalledIndividuals(GameTestHelper h){
for(boolean knockedOut:new boolean[]{true,false}){
var p=longLife(h);var b=FamiliarBattle.bond(p);
var expected=knockedOut?b.health(0,FamiliarBattle.now()+90000):b.recall(true);
FamiliarBattle.update(p,expected);
var next=dieAndRespawn(h,p,true);FamiliarService.tick(next);
h.assertTrue(FamiliarService.active(next)==null,"Respawn cannot bypass KO or recall");
h.assertTrue(FamiliarBattle.bond(next).knockedOutUntil()==expected.knockedOutUntil()
&&(knockedOut?FamiliarBattle.bond(next).health()==0:FamiliarBattle.bond(next).recalled()),"Durable familiar state is retained");
cleanup(next);
}h.succeed();
}
private static ServerPlayer longLife(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.WOLF);p.tickCount=24000;
FamiliarBattle.command(p,"protect");FamiliarBattle.command(p,"refresh");
return p;
}
private static ServerPlayer dieAndRespawn(GameTestHelper h,ServerPlayer p,boolean keep){
var rules=h.getLevel().getGameRules();boolean before=rules.get(GameRules.KEEP_INVENTORY);
try{
rules.set(GameRules.KEEP_INVENTORY,keep,h.getLevel().getServer());
p.setHealth(0);p.die(p.damageSources().genericKill());
var next=Progression003GameTests.player(h);next.setUUID(p.getUUID());
next.restoreFrom(p,false);next.setId(p.getId());next.setPos(p.position());next.setHealth(next.getMaxHealth());
next.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());
h.assertTrue(next!=p&&next.equals(p)&&next.tickCount==0,"Fixture reproduces native respawn identity and clock reset");
ServerPlayerEvents.AFTER_RESPAWN.invoker().afterRespawn(p,next,false);
return next;
}finally{rules.set(GameRules.KEEP_INVENTORY,before,h.getLevel().getServer());}
}
private static void commands(GameTestHelper h,ServerPlayer p){
FamiliarBattle.command(p,"follow");
h.assertTrue(FamiliarBattle.state(p).lastRequest==0&&FamiliarBattle.state(p).order==FamiliarBattle.Order.FOLLOW,"First order after respawn is accepted immediately");
FamiliarBattle.command(p,"refresh");
h.assertTrue(FamiliarBattle.state(p).lastRefresh==0,"First orders snapshot is accepted immediately");
p.tickCount=3;FamiliarBattle.command(p,"protect");
h.assertTrue(FamiliarBattle.state(p).order==FamiliarBattle.Order.PROTECT,"Subsequent commands use the new clock");
p.tickCount=10;
}
private static void cleanup(ServerPlayer p){
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);
FamiliarService.tick(p);FamiliarBattle.release(p);CompanionService.clear(p);
}
}
@@ -0,0 +1,110 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.cosmetics.HeadBlockService;
import java.util.UUID;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.core.component.DataComponents;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.*;
import net.minecraft.util.ProblemReporter;
/** Purchase persistence and real native repairs/upgrades, including worn workstations. */
public final class Forgerie238GameTests {
@GameTest public void purchaseIsPermanentAndSurvivesSaveDeathAndCycle(GameTestHelper h) {
var r=ProgressRecord.INITIAL;
for(String skill:ProgressRecord.SKILLS)while(r.rank(skill)<ProgressRecord.cap(skill))r=r.buy(skill,r.cost(skill,ProgressionConfig.DEFAULT));
var p=player(h,r);String proof=p.getAttached(ProgressionService.RECORD);
h.assertTrue(!p.hasAttached(CraftAptitudes.SMITHING) && !CraftAptitudes.learned(p,"smithing"),"Existing players start without the new purchase");
p.setExperienceLevels(3);h.assertTrue(!CraftAptitudes.buy(p,"smithing",r.revision()) && p.experienceLevel==3,"Three levels cannot unlock Forgerie");
p.setExperienceLevels(4);h.assertTrue(!CraftAptitudes.buy(p,"smithing",r.revision()-1) && p.experienceLevel==4,"Stale purchase cannot spend XP");
h.assertTrue(CraftAptitudes.buy(p,"smithing",r.revision()) && p.experienceLevel==0,"Four levels acquire Forgerie once");
p.setExperienceLevels(8);h.assertTrue(!CraftAptitudes.buy(p,"smithing",r.revision()) && p.experienceLevel==8,"Repeat purchase costs nothing");
h.assertTrue(!AptitudeService.set(p,new AptitudeService.Set("smithing",false,0)) && CraftAptitudes.allowed(p,"smithing"),"Bought Forgerie has no disable request");
h.assertTrue(!CraftAptitudes.learned(p,"enchanting") && !CraftAptitudes.learned(p,"alchemy") && proof.equals(p.getAttached(ProgressionService.RECORD)),"Other purchases and progression proofs stay independent");
var output=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());p.saveWithoutId(output);
var loaded=Progression003GameTests.player(h);loaded.load(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),output.buildResult()));
h.assertTrue(CraftAptitudes.learned(loaded,"smithing") && loaded.experienceLevel==8 && proof.equals(loaded.getAttached(ProgressionService.RECORD)),"Native NBT reload preserves the purchase and XP");
var respawn=Progression003GameTests.player(h);respawn.setUUID(p.getUUID());respawn.restoreFrom(p,false);
ServerPlayerEvents.AFTER_RESPAWN.invoker().afterRespawn(p,respawn,false);
h.assertTrue(CraftAptitudes.learned(respawn,"smithing"),"Forgerie survives ordinary death");
bind(p,r.nextCycle());h.assertTrue(CraftAptitudes.learned(p,"smithing") && CraftAptitudes.allowed(p,"smithing"),"Forgerie survives New Game+");h.succeed();
}
@GameTest public void allAnvilsRequirePurchaseThenRepairAndRename(GameTestHelper h) {
var p=player(h,ProgressRecord.INITIAL);var at=p.blockPosition();h.getLevel().setBlockAndUpdate(at.below(),Blocks.STONE.defaultBlockState());
for(var block:new net.minecraft.world.level.block.Block[]{Blocks.ANVIL,Blocks.CHIPPED_ANVIL,Blocks.DAMAGED_ANVIL}) {
h.getLevel().setBlockAndUpdate(at,block.defaultBlockState());
h.assertTrue(p.openMenu(h.getLevel().getBlockState(at).getMenuProvider(h.getLevel(),at)).isEmpty(),"Every anvil variant is locked before purchase");
}
p.setExperienceLevels(40);h.assertTrue(CraftAptitudes.buy(p,"smithing",0) && p.experienceLevel==36,"Anvil unlock charges four levels");
for(var block:new net.minecraft.world.level.block.Block[]{Blocks.ANVIL,Blocks.CHIPPED_ANVIL,Blocks.DAMAGED_ANVIL}) {
h.getLevel().setBlockAndUpdate(at,block.defaultBlockState());
h.assertTrue(p.openMenu(h.getLevel().getBlockState(at).getMenuProvider(h.getLevel(),at)).isPresent(),"Every acquired anvil variant opens");
var menu=(AnvilMenu)p.containerMenu;var sword=new ItemStack(Items.DIAMOND_SWORD);sword.setDamageValue(100);
menu.getSlot(AnvilMenu.INPUT_SLOT).set(sword);menu.getSlot(AnvilMenu.ADDITIONAL_SLOT).set(new ItemStack(Items.DIAMOND));
h.assertTrue(menu.setItemName("Forgerie238") && menu.stillValid(p),"Native anvil accepts a repair and name");
int cost=menu.getCost(),xp=p.experienceLevel;
var result=menu.getSlot(AnvilMenu.RESULT_SLOT).safeTake(1,1,p);
h.assertTrue(result.is(Items.DIAMOND_SWORD) && result.getDamageValue()==0 && result.get(DataComponents.CUSTOM_NAME).getString().equals("Forgerie238"),"Native result is repaired and renamed");
h.assertTrue(cost>0 && p.experienceLevel==xp-cost && menu.getSlot(AnvilMenu.ADDITIONAL_SLOT).getItem().isEmpty(),"Native operation consumes its normal XP and repair material");p.closeContainer();
}
h.succeed();
}
@GameTest public void smithingTableUpgradesAndTrimsAfterPurchase(GameTestHelper h) {
var p=player(h,ProgressRecord.INITIAL);var at=p.blockPosition();h.getLevel().setBlockAndUpdate(at,Blocks.SMITHING_TABLE.defaultBlockState());
var provider=h.getLevel().getBlockState(at).getMenuProvider(h.getLevel(),at);
h.assertTrue(p.openMenu(provider).isEmpty(),"Smithing table is locked before purchase");
p.setExperienceLevels(4);h.assertTrue(CraftAptitudes.buy(p,"smithing",0) && p.openMenu(provider).isPresent(),"Same purchase opens smithing table");
var menu=(SmithingMenu)p.containerMenu;
inputs(menu,Items.NETHERITE_UPGRADE_SMITHING_TEMPLATE,Items.DIAMOND_SWORD,Items.NETHERITE_INGOT);
var upgrade=menu.getSlot(SmithingMenu.RESULT_SLOT).safeTake(1,1,p);
h.assertTrue(upgrade.is(Items.NETHERITE_SWORD) && p.experienceLevel==0,"Real netherite upgrade works without extra XP");
h.assertTrue(menu.getSlot(0).getItem().isEmpty() && menu.getSlot(1).getItem().isEmpty() && menu.getSlot(2).getItem().isEmpty(),"Upgrade consumes each native input once");
inputs(menu,Items.SENTRY_ARMOR_TRIM_SMITHING_TEMPLATE,Items.DIAMOND_CHESTPLATE,Items.REDSTONE);
var trimmed=menu.getSlot(SmithingMenu.RESULT_SLOT).safeTake(1,1,p);
h.assertTrue(trimmed.is(Items.DIAMOND_CHESTPLATE) && trimmed.has(DataComponents.TRIM),"Native armor trim remains available");
p.closeContainer();h.assertTrue(p.openMenu(provider).isPresent(),"Forgerie remains available on later visits");p.closeContainer();h.succeed();
}
@GameTest public void forgedMenusCannotYieldAnUnownedResult(GameTestHelper h) {
var p=player(h,ProgressRecord.INITIAL);p.setExperienceLevels(20);var at=p.blockPosition();
h.getLevel().setBlockAndUpdate(at,Blocks.ANVIL.defaultBlockState());
var anvil=new AnvilMenu(7,p.getInventory(),ContainerLevelAccess.create(h.getLevel(),at));
anvil.getSlot(0).set(new ItemStack(Items.DIAMOND_SWORD));anvil.setItemName("Forbidden");
h.assertTrue(anvil.getSlot(AnvilMenu.RESULT_SLOT).hasItem() && !anvil.stillValid(p) && !anvil.getSlot(AnvilMenu.RESULT_SLOT).mayPickup(p),"Anvil constructed outside normal opening is still locked");
h.assertTrue(anvil.getSlot(AnvilMenu.RESULT_SLOT).safeTake(1,1,p).isEmpty() && p.experienceLevel==20 && anvil.getSlot(0).hasItem(),"Rejected anvil take changes neither XP nor inputs");anvil.removed(p);
h.getLevel().setBlockAndUpdate(at,Blocks.SMITHING_TABLE.defaultBlockState());
var forge=new SmithingMenu(8,p.getInventory(),ContainerLevelAccess.create(h.getLevel(),at));inputs(forge,Items.NETHERITE_UPGRADE_SMITHING_TEMPLATE,Items.DIAMOND_SWORD,Items.NETHERITE_INGOT);
h.assertTrue(forge.getSlot(SmithingMenu.RESULT_SLOT).hasItem() && !forge.stillValid(p) && !forge.getSlot(SmithingMenu.RESULT_SLOT).mayPickup(p),"Smithing's inherited result gate rejects an unowned menu");
h.assertTrue(forge.getSlot(SmithingMenu.RESULT_SLOT).safeTake(1,1,p).isEmpty() && forge.getSlot(1).hasItem(),"Rejected smithing take never consumes its base item");forge.removed(p);
var vanilla=Progression003GameTests.player(h);h.assertTrue(CraftAptitudes.allowed(vanilla,"smithing"),"Players outside Sanctuary progression retain vanilla access");h.succeed();
}
@GameTest public void wornWorkstationsRespectTheVisitorsPurchase(GameTestHelper h) {
var owner=player(h,ProgressRecord.INITIAL);var actor=player(h,ProgressRecord.INITIAL);actor.setPos(owner.position().add(1,0,0));
Item[] items={Items.ANVIL,Items.CHIPPED_ANVIL,Items.DAMAGED_ANVIL,Items.SMITHING_TABLE};
for(var item:items) {
AccessoryAccess.of(owner).setItem(AccessoryContainer.HEAD,new ItemStack(item));
HeadBlockService.use(actor,owner,false);h.assertTrue(actor.containerMenu==actor.inventoryMenu,"Worn workstation cannot bypass the visitor's locked aptitude: "+item);
}
actor.setExperienceLevels(4);h.assertTrue(CraftAptitudes.buy(actor,"smithing",0),"Visitor purchases Forgerie");
for(var item:items) {
AccessoryAccess.of(owner).setItem(AccessoryContainer.HEAD,new ItemStack(item));
h.assertTrue(HeadBlockService.use(actor,owner,false) && actor.containerMenu!=actor.inventoryMenu,"Visitor uses acquired worn workstation: "+item);
var menu=actor.containerMenu;h.assertTrue(HeadBlockService.validMenu(menu,actor,()->menu.stillValid(actor)),"Native worn menu stays valid for learned visitor");actor.closeContainer();
}
AccessoryAccess.of(owner).removeItemNoUpdate(AccessoryContainer.HEAD);h.succeed();
}
private static void inputs(SmithingMenu menu,Item template,Item base,Item addition) {menu.getSlot(0).set(new ItemStack(template));menu.getSlot(1).set(new ItemStack(base));menu.getSlot(2).set(new ItemStack(addition));}
private static ServerPlayer player(GameTestHelper h,ProgressRecord record) {
var p=Progression003GameTests.player(h);p.setUUID(UUID.randomUUID());
try {var identities=ProgressionService.session(h.getLevel().getServer()).inhabitants;identities.create(p.getUUID(),"forge"+p.getUUID().toString().substring(0,8),"",identities.available().getFirst());}
catch(Exception e){throw new AssertionError(e);}
bind(p,record);return p;
}
private static void bind(ServerPlayer p,ProgressRecord record){p.setAttached(ProgressionService.RECORD,record.json());ProgressEffects.apply(p,record);}
}
@@ -0,0 +1,34 @@
package fr.koka.sanctuary.gametest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
/** Test-only wall intervals between rendered frames, including ticks, submission and waits. */
public final class Frame256Metrics {
public static volatile boolean recording;
private static final ArrayList<Long> intervals = new ArrayList<>();
private static long previous;
private Frame256Metrics() { }
public static synchronized void beginFrame(long now) {
if (!recording) return;
if (previous != 0) intervals.add(now - previous);
previous = now;
}
public static synchronized void start() { intervals.clear(); previous = 0; recording = true; }
public static synchronized Map<String, Object> stop() {
recording = false;
var sorted = intervals.stream().mapToLong(Long::longValue).sorted().toArray();
if (sorted.length < 10) throw new AssertionError("Too few real frame intervals");
double mean = intervals.stream().mapToLong(Long::longValue).average().orElseThrow() / 1e6;
var result = new LinkedHashMap<String, Object>();
result.put("frames", sorted.length); result.put("meanFrameMs", mean);
result.put("averageFps", 1000 / mean); result.put("medianFrameMs", quantile(sorted, .5));
result.put("p95FrameMs", quantile(sorted, .95)); result.put("p99FrameMs", quantile(sorted, .99));
result.put("maxFrameMs", sorted[sorted.length - 1] / 1e6);
result.put("framesOver16ms", intervals.stream().filter(n -> n > 16_666_667).count());
result.put("framesOver50ms", intervals.stream().filter(n -> n > 50_000_000).count());
return result;
}
private static double quantile(long[] sorted, double q) { return sorted[Math.min(sorted.length-1, (int)(sorted.length*q))] / 1e6; }
}
@@ -68,6 +68,7 @@ public class HeadBlocks043GameTests {
}
@GameTest public void nativeWorkstationsOpenAndCloseAfterMovement(GameTestHelper h){
var owner=player(h);var actor=player(h);actor.setPos(owner.position().add(1,0,0));
for(var target:new String[]{"enchanting","alchemy","smithing"}){actor.setExperienceLevels(4);h.assertTrue(CraftAptitudes.buy(actor,target,0),"Native workstation fixture owns "+target);}
Item[] items={Items.CRAFTING_TABLE,Items.CHEST,Items.TRAPPED_CHEST,Items.BARREL,Items.ENDER_CHEST,Items.SHULKER_BOX,
Items.FURNACE,Items.BLAST_FURNACE,Items.SMOKER,Items.BREWING_STAND,Items.HOPPER,Items.DISPENSER,Items.DROPPER,
Items.CRAFTER,Items.STONECUTTER,Items.SMITHING_TABLE,Items.LOOM,Items.CARTOGRAPHY_TABLE,Items.GRINDSTONE,Items.ANVIL,Items.ENCHANTING_TABLE,Items.BEACON};
@@ -0,0 +1,322 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.insects.*;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.*;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.*;
import net.minecraft.world.item.*;
import net.minecraft.world.item.crafting.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
public final class Insect245GameTests {
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void seasonalHoursPeaksAndMidnight(GameTestHelper h) {
var common=InsectCatalog.byId("common_butterfly");var grass=InsectCatalog.byId("grasshopper");
h.assertTrue(InsectCatalog.ALL.size()==47 && common.available(3,240,false) && !common.available(3,239,false)
&& !common.available(3,1140,false) && !common.available(2,600,false),"Butterfly month and exact start/end hours");
h.assertTrue(common.available(7,480,false) && !common.available(7,479,false) && !common.available(9,960,false),"Monthly hour variants");
h.assertTrue(common.spawnWeight(3,600,false)==common.weight()*3 && common.spawnWeight(3,300,false)==common.weight(),"Peak boosts occurrence threefold within availability");
h.assertTrue(!grass.available(7,900,false) && grass.available(7,960,false) && grass.available(8,600,false) && grass.peak(9,600,false),"Grasshopper narrow July hour and September peak");
var cricket=InsectCatalog.byId("cricket");
h.assertTrue(cricket.available(10,1380,false) && cricket.available(10,60,false) && !cricket.available(10,480,false),"Night intervals cross midnight");
h.assertTrue(common.available(9,600,true) && !common.available(6,600,true) && InsectCatalog.byId("mole_cricket").peak(1,720,false),"Southern seasons shift six months and winter peak wraps December/February");
h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void netRecipeUsesTwoSticksAndThreeStrings(GameTestHelper h) {
var input=CraftingInput.of(3,3,Arrays.asList(null,Items.STRING,Items.STRING,null,Items.STICK,Items.STRING,Items.STICK,null,null)
.stream().map(i->i==null?ItemStack.EMPTY:new ItemStack(i)).toList());
var recipe=h.getLevel().getServer().getRecipeManager().getRecipeFor(RecipeType.CRAFTING,input,h.getLevel()).orElseThrow();
h.assertTrue(recipe.value().assemble(input).is(Insects.NET),"Diagonal shaft and upper right mesh craft the real net");h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void surfacesFlowersWaterAndRain(GameTestHelper h) {
var level=h.getLevel();var ground=h.absolutePos(new BlockPos(2,3,2));
level.setBlockAndUpdate(ground,Blocks.GRASS_BLOCK.defaultBlockState());level.setBlockAndUpdate(ground.above(),Blocks.AIR.defaultBlockState());
h.assertTrue(InsectSpawner.findSite(level,InsectCatalog.byId("cricket"),ground)!=null,"Ground insect has solid terrain: "+level.getBlockState(ground)
+" dirt="+level.getBlockState(ground).is(net.minecraft.tags.BlockTags.DIRT)+" above="+level.getBlockState(ground.above())+" habitat="+InsectCatalog.byId("cricket").habitat());
level.setBlockAndUpdate(ground.above(),Blocks.POPPY.defaultBlockState());
h.assertTrue(InsectSpawner.findSite(level,InsectCatalog.byId("tiger_butterfly"),ground)!=null
&& InsectSpawner.findSite(level,InsectCatalog.byId("orchid_mantis"),ground)==null,"Flower colour gates butterflies and orchid mantis");
level.setBlockAndUpdate(ground.above(),Blocks.WHITE_TULIP.defaultBlockState());
h.assertTrue(InsectSpawner.findSite(level,InsectCatalog.byId("orchid_mantis"),ground)!=null,"White flowers host orchid mantis");
level.getWeatherData().setRaining(false);level.setRainLevel(0);
h.assertTrue(!InsectSpawner.supports(level,InsectCatalog.byId("snail"),ground.above(),Direction.UP),"Snail cannot spawn without actual local rain");
level.setBlockAndUpdate(ground.above(),Blocks.AIR.defaultBlockState());level.setBlockAndUpdate(ground,Blocks.WATER.defaultBlockState());
var site=InsectSpawner.findSite(level,InsectCatalog.byId("pondskater"),ground);
h.assertTrue(site!=null && Math.abs(site.position().y-ground.getY()-.8988889)<.02,"Pondskater lies at the native freshwater surface");
level.setBlockAndUpdate(ground.above(),Blocks.WATER.defaultBlockState());
h.assertTrue(!InsectSpawner.supports(level,InsectCatalog.byId("pondskater"),ground,Direction.UP),"Submerged water cannot host surface insects");h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void trunkFacesRemainAttachedAndPalmsUseJungle(GameTestHelper h) {
var level=h.getLevel();var log=h.absolutePos(new BlockPos(3,4,3));
level.setBlockAndUpdate(log,Blocks.OAK_LOG.defaultBlockState());
for(var face:Direction.Plane.HORIZONTAL) {
level.setBlockAndUpdate(log.relative(face),Blocks.AIR.defaultBlockState());
h.assertTrue(InsectSpawner.supports(level,InsectCatalog.byId("cockroach"),log,face),"Insect fits bark face "+face);
}
h.assertTrue(!InsectSpawner.supports(level,InsectCatalog.byId("cockroach"),log,Direction.UP)
&& !InsectSpawner.supports(level,InsectCatalog.byId("goliath_beetle"),log,Direction.NORTH),"No billboard on top of log; palm species reject oak");
level.setBlockAndUpdate(log,Blocks.JUNGLE_LOG.defaultBlockState());
h.assertTrue(InsectSpawner.supports(level,InsectCatalog.byId("goliath_beetle"),log,Direction.NORTH),"Jungle trunks are the documented palm analogue");
level.setBlockAndUpdate(log.north(),Blocks.STONE.defaultBlockState());
h.assertTrue(!InsectSpawner.supports(level,InsectCatalog.byId("goliath_beetle"),log,Direction.NORTH),"Occupied bark face rejects spawning");h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void sneakScareDamageAndEscape(GameTestHelper h) {
var player=player(h);var insect=insect(h,"ant",player.position().add(1.8,0,0));
player.setShiftKeyDown(false);h.assertTrue(!insect.notices(player) && !insect.notices(player),"Stationary proximity is safe");
player.setPos(player.position().add(-.1,0,0));h.assertTrue(!insect.notices(player),"Moving away does not startle");
player.setPos(player.position().add(.2,0,0));h.assertTrue(insect.notices(player),"Walking towards a close insect scares it");
player.setShiftKeyDown(true);player.setPos(player.position().add(.1,0,0));h.assertTrue(!insect.notices(player),"Sneaking permits approach");
player.setPos(insect.position().add(-.4,0,0));h.assertTrue(insect.notices(player),"Approaching touching distance still scares while sneaking");
float health=player.getHealth();h.assertTrue(!insect.hurtServer(h.getLevel(),player.damageSources().playerAttack(player),10) && insect.fleeing(),"Attacking initiates flight without damage/loot");
for(int i=0;i<24;i++)step(insect);
h.assertTrue(insect.isRemoved() && player.getHealth()==health,"Escape ends in disappearance and never injures the player");h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void netCapturesOnceAndRejectsWallsRangeAndFleeing(GameTestHelper h) {
var player=player(h);Vec3 eye=player.getEyePosition();
var target=insect(h,"cockroach",eye.add(2,0,0));
h.assertTrue(InsectNetItem.sweep(player,InteractionHand.MAIN_HAND) && target.isRemoved(),"Aimed sweep captures one insect");
int count=player.getInventory().countItem(Insects.specimen(InsectCatalog.byId("cockroach")));
h.assertTrue(count==1 && !InsectNetItem.sweep(player,InteractionHand.MAIN_HAND),"Repeated sweep cannot duplicate the captured specimen");
var far=insect(h,"cockroach",eye.add(4,0,0));h.assertTrue(!InsectNetItem.sweep(player,InteractionHand.MAIN_HAND),"Out of reach cannot be captured");far.discard();
var off=insect(h,"cockroach",eye.add(2,0,1));h.assertTrue(InsectNetItem.sweep(player,InteractionHand.MAIN_HAND) && off.isRemoved(),"Nearby off-axis insect needs no pixel aim or sneak");
var rear=insect(h,"cockroach",eye.add(-2,0,0));h.assertTrue(!InsectNetItem.sweep(player,InteractionHand.MAIN_HAND),"Sweep does not catch behind the player");rear.discard();
var behind=insect(h,"cockroach",eye.add(2.5,0,0));var wall=BlockPos.containing(eye.add(1,0,0));
h.getLevel().setBlockAndUpdate(wall,Blocks.STONE.defaultBlockState());h.assertTrue(!InsectNetItem.sweep(player,InteractionHand.MAIN_HAND),"Wall occludes capture");
h.getLevel().setBlockAndUpdate(wall,Blocks.AIR.defaultBlockState());behind.scareFrom(player.position());
h.assertTrue(!InsectNetItem.sweep(player,InteractionHand.MAIN_HAND),"Fleeing insect is no longer capturable");h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void standingNetChoosesNearestVisibleGroundInsect(GameTestHelper h) {
var player=player(h);player.setShiftKeyDown(false);player.setXRot(0);
var farther=insect(h,"ant",player.position().add(2.7,.02,0));
var nearer=insect(h,"cricket",player.position().add(1.4,.02,.8));
h.assertTrue(InsectNetItem.sweep(player,InteractionHand.MAIN_HAND) && nearer.isRemoved() && !farther.isRemoved(),"Standing level gaze picks the nearest ground insect");
h.assertTrue(player.getInventory().countItem(Insects.specimen(InsectCatalog.byId("cricket")))==1,"Nearest species reward, exactly once");
h.assertTrue(InsectNetItem.sweep(player,InteractionHand.MAIN_HAND) && farther.isRemoved(),"Next sweep can collect the next nearest insect");h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void fleaStaysOnHostAndFlatSpeciesNeverHop(GameTestHelper h) {
var player=player(h);var level=h.getLevel();
var villager=new net.minecraft.world.entity.npc.villager.Villager(net.minecraft.world.entity.EntityTypes.VILLAGER,level);
villager.setPos(player.position().add(1,0,0));villager.setNoAi(true);level.addFreshEntity(villager);
var flea=insect(h,"flea",villager.position().add(.2,1.1,0));
flea.notices(player);player.setPos(player.position().add(.8,0,0));
h.assertTrue(!flea.notices(player),"Approach cannot scare a hosted flea");
flea.scareFrom(player.position());flea.hurtServer(level,player.damageSources().playerAttack(player),1);
h.assertTrue(!flea.fleeing(),"Attack cannot detach a flea into a fleeing insect");
villager.setPos(villager.position().add(1,0,1));step(flea);
h.assertTrue(flea.position().distanceToSqr(villager.position().add(.2,1.1,0))<.02,"Flea continues to follow its host");
for(String id:List.of("ant","cricket","bell_cricket","mole_cricket","pill_bug")) {
var bug=insect(h,id,player.position().add(4,0,0));Vec3 origin=bug.position();
h.assertTrue(bug.species().shape()==InsectCatalog.Shape.FLAT,"Ground-facing sprite: "+id);
for(int t=0;t<18;t++)step(bug);
h.assertTrue(!bug.isRemoved() && bug.position().equals(origin),"No hop: "+id+" removed="+bug.isRemoved()+" delta="+bug.position().subtract(origin));bug.discard();
}
h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void snailContactsFlowerAndSnowballRollsWithBeetle(GameTestHelper h) {
var level=h.getLevel();var support=h.absolutePos(new BlockPos(3,3,3));
level.setBlockAndUpdate(support,Blocks.GRASS_BLOCK.defaultBlockState());level.setBlockAndUpdate(support.above(),Blocks.WHITE_TULIP.defaultBlockState());
double top=level.getBlockState(support.above()).getShape(level,support.above()).max(Direction.Axis.Y);
double height=InsectSpawner.surfaceHeight(level,support.above(),InsectCatalog.Habitat.RAIN_FLOWER);
h.assertTrue(Math.abs(height-top-.005)<.0001,"Snail contact uses the actual native flower height");
var beetle=insect(h,"dung_beetle",h.absoluteVec(new Vec3(1.5,5,1.5)));Vec3 origin=beetle.position();
for(int t=0;t<18;t++)step(beetle);
h.assertTrue(beetle.position().distanceToSqr(origin)>.01 && beetle.snowRoll()>0,"Beetle moves and synchronizes snowball rotation: delta="+beetle.position().subtract(origin)+" roll="+beetle.snowRoll()+" removed="+beetle.isRemoved());
h.assertTrue(Math.abs(beetle.getY()-origin.y)<.0001,"Snowball push stays on the ground");h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void dragonflyAcceleratesTowardsWaypoint(GameTestHelper h) {
var bug=insect(h,"darner_dragonfly",h.absoluteVec(new Vec3(3.5,6,3.5)));Vec3 origin=bug.position();step(bug);
double first=origin.distanceTo(bug.position()),latest=first;
for(int t=0;t<15;t++){Vec3 previous=bug.position();step(bug);latest=previous.distanceTo(bug.position());}
h.assertTrue(bug.species().shape()==InsectCatalog.Shape.DRAGONFLY && first>0 && latest>first*2,"Custom dragonfly accelerates between waypoints: first="+first+" latest="+latest+" removed="+bug.isRemoved());
h.assertTrue(origin.distanceToSqr(bug.position())<4 && !bug.isRemoved(),"Waypoint flight remains bounded to its habitat");
h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void grasshoppersTravelInArcsAndRestAfterLanding(GameTestHelper h) {
hoppingGround(h);
for(String id:List.of("long_locust","migratory_locust","grasshopper")) {
var hopper=insect(h,id,h.absoluteVec(new Vec3(6.5,5.02,6.5)));Vec3 origin=hopper.position();double peak=origin.y;
for(int t=0;t<20;t++) {step(hopper);peak=Math.max(peak,hopper.getY());}
Vec3 landed=hopper.position();
h.assertTrue(!hopper.isRemoved() && peak-origin.y>.6 && landed.subtract(origin).horizontalDistanceSqr()>.7,"Raised jump travels across real terrain: "+id+" delta="+landed.subtract(origin));
h.assertTrue(Math.abs(landed.y-origin.y)<.001,"Jump returns to the ground: "+id);
for(int t=0;t<24;t++) {step(hopper);h.assertTrue(hopper.position().equals(landed),"No sliding between jumps: "+id);}
for(int t=0;t<180;t++) {
step(hopper);
h.assertTrue(!hopper.isRemoved() && hopper.position().subtract(origin).horizontalDistanceSqr()<=6.251 && hopper.getY()>=origin.y-.001,"Wandering stays above its supported home terrain: "+id);
}
hopper.discard();
}
h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void grasshoppersFleeWithRapidBondsAndCanTurnMidair(GameTestHelper h) {
hoppingGround(h);
for(String id:List.of("long_locust","migratory_locust","grasshopper")) {
var hopper=insect(h,id,h.absoluteVec(new Vec3(6.5,5.02,6.5)));Vec3 origin=hopper.position();
for(int t=0;t<20;t++)step(hopper);
double ordinaryDistance=hopper.position().subtract(origin).horizontalDistance();Vec3 escape=hopper.position();
hopper.scareFrom(escape.add(-2,0,0));
double peak=escape.y;
for(int t=1;t<=23;t++) {
step(hopper);peak=Math.max(peak,hopper.getY());
h.assertTrue(!hopper.isRemoved() && hopper.getX()>escape.x,"Fleeing hops head away from the threat: "+id);
if(t==8 || t==16)h.assertTrue(Math.abs(hopper.getY()-escape.y)<.001,"Rapid flight lands repeatedly, rather than gliding: "+id);
}
h.assertTrue(peak-escape.y>.6 && hopper.getX()-escape.x>ordinaryDistance*2,"Fleeing covers ground faster than the ordinary jump: "+id);
step(hopper);h.assertTrue(hopper.isRemoved(),"Rapid hopping still ends in disappearance: "+id);
var airborne=insect(h,id,origin);for(int t=0;t<6;t++)step(airborne);Vec3 turn=airborne.position();
airborne.scareFrom(turn.add(-2,0,0));h.assertTrue(airborne.position().equals(turn),"Startling a midair insect cannot teleport it: "+id);
step(airborne);h.assertTrue(airborne.position().distanceTo(turn)<.5 && airborne.getX()>turn.x,"Midair flight continues from its current position: "+id);airborne.discard();
}
h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void grasshoppersRejectWallsAndWetLandings(GameTestHelper h) {
hoppingGround(h);var level=h.getLevel();
for(int x=5;x<=7;x++)for(int z=5;z<=7;z++)if(x!=6 || z!=6)
for(int y=5;y<=7;y++)level.setBlockAndUpdate(h.absolutePos(new BlockPos(x,y,z)),Blocks.STONE.defaultBlockState());
var trapped=insect(h,"long_locust",h.absoluteVec(new Vec3(6.5,5.02,6.5)));Vec3 origin=trapped.position();
for(int t=0;t<40;t++)step(trapped);
h.assertTrue(!trapped.isRemoved() && trapped.position().equals(origin),"A closed wall prevents hops through solid blocks");
trapped.scareFrom(origin.add(-2,0,0));for(int t=0;t<23;t++)step(trapped);
h.assertTrue(trapped.position().equals(origin),"Rapid flight cannot tunnel through a wall");step(trapped);h.assertTrue(trapped.isRemoved(),"Blocked escape still expires");
hoppingGround(h);
for(int x=4;x<=8;x++)for(int z=4;z<=8;z++)if(x!=6 || z!=6)
level.setBlockAndUpdate(h.absolutePos(new BlockPos(x,4,z)),Blocks.WATER.defaultBlockState());
var dry=insect(h,"grasshopper",origin);for(int t=0;t<40;t++)step(dry);
h.assertTrue(dry.position().equals(origin),"An isolated grass cell does not permit a jump into surrounding water");dry.discard();h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void hoppersNeedOpenGrassAndTreesKeepTheirLeafCover(GameTestHelper h) {
hoppingGround(h);var level=h.getLevel();var pos=h.absolutePos(new BlockPos(6,4,6));
for(var id:List.of("long_locust","migratory_locust","grasshopper")) {
var species=InsectCatalog.byId(id);
h.assertTrue(InsectSpawner.findSite(level,species,pos)!=null,"Open grass remains a natural habitat: "+id);
for(var block:List.of(Blocks.STONE,Blocks.SAND,Blocks.COBBLESTONE,Blocks.DIRT,Blocks.OAK_PLANKS)) {
level.setBlockAndUpdate(pos,block.defaultBlockState());
h.assertTrue(InsectSpawner.findSite(level,species,pos)==null,"Hopper rejects bare or constructed terrain: "+id+" "+block);
}
level.setBlockAndUpdate(pos,Blocks.GRASS_BLOCK.defaultBlockState());
}
var leaves=pos.above(4);level.setBlockAndUpdate(leaves,Blocks.OAK_LEAVES.defaultBlockState());
try {h.assertTrue(InsectSpawner.findSite(level,InsectCatalog.byId("long_locust"),pos)==null,"Canopy shade is not an open meadow");}
finally {level.setBlockAndUpdate(leaves,Blocks.AIR.defaultBlockState());}
var log=pos.above();var canopy=log.north().above(3);
level.setBlockAndUpdate(log,Blocks.OAK_LOG.defaultBlockState());level.setBlockAndUpdate(canopy,Blocks.OAK_LEAVES.defaultBlockState());
try {h.assertTrue(InsectSpawner.supports(level,InsectCatalog.byId("cockroach"),log,Direction.NORTH),"Leaf cover still permits forest insects on the bark");}
finally {level.setBlockAndUpdate(log,Blocks.AIR.defaultBlockState());level.setBlockAndUpdate(canopy,Blocks.AIR.defaultBlockState());}
h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void dragonfliesUseGrassOrFreshwaterBanksAndRejectStone(GameTestHelper h) {
hoppingGround(h);var level=h.getLevel();var pos=h.absolutePos(new BlockPos(6,4,6));var water=pos.offset(3,0,0);
for(var id:List.of("red_dragonfly","darner_dragonfly","banded_dragonfly")) {
var species=InsectCatalog.byId(id);
h.assertTrue(InsectSpawner.findSite(level,species,pos)!=null,"Dragonflies remain available in open grass: "+id);
level.setBlockAndUpdate(pos,Blocks.SAND.defaultBlockState());
h.assertTrue(InsectSpawner.findSite(level,species,pos)==null,"Dry sand is not a dragonfly bank: "+id);
level.setBlockAndUpdate(water,Blocks.WATER.defaultBlockState());
h.assertTrue(InsectSpawner.findSite(level,species,pos)!=null,"Freshwater makes the sandy bank valid: "+id);
level.setBlockAndUpdate(pos,Blocks.STONE.defaultBlockState());
h.assertTrue(InsectSpawner.findSite(level,species,pos)==null,"Bare rock is refused even next to water: "+id);
level.setBlockAndUpdate(pos,Blocks.GRASS_BLOCK.defaultBlockState());level.setBlockAndUpdate(water,Blocks.GRASS_BLOCK.defaultBlockState());
}
h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void naturalInsectsRejectOpaqueGlassAndSlabRoofs(GameTestHelper h) {
hoppingGround(h);var level=h.getLevel();var ground=h.absolutePos(new BlockPos(6,4,6));
var log=ground.offset(2,1,0);var flower=ground.offset(0,1,2);var rock=ground.offset(-1,0,0);var water=ground.offset(-2,0,0);
level.setBlockAndUpdate(log,Blocks.OAK_LOG.defaultBlockState());level.setBlockAndUpdate(flower,Blocks.POPPY.defaultBlockState());
level.setBlockAndUpdate(rock,Blocks.STONE.defaultBlockState());level.setBlockAndUpdate(water,Blocks.WATER.defaultBlockState());
var pondSite=InsectSpawner.findSite(level,InsectCatalog.byId("pondskater"),water);
var pond=InsectSpawner.spawn(level,InsectCatalog.byId("pondskater"),pondSite);
h.assertTrue(pond!=null,"Outdoor pondskater contact inside the water cell remains valid");pond.discard();
var species=List.of("long_locust","red_dragonfly","cricket","cockroach","common_butterfly","pill_bug","pondskater");
var supports=List.of(ground,ground,ground,log,flower,rock,water);
for(int i=0;i<species.size();i++)h.assertTrue(InsectSpawner.supports(level,InsectCatalog.byId(species.get(i)),supports.get(i),i==3?Direction.NORTH:Direction.UP),"Outdoor control habitat: "+species.get(i));
try {
for(var roof:List.of(Blocks.OAK_PLANKS,Blocks.GLASS,Blocks.STONE_SLAB)) {
for(int x=-2;x<=2;x++)for(int z=-2;z<=2;z++)level.setBlockAndUpdate(ground.offset(x,4,z),roof.defaultBlockState());
for(int i=0;i<species.size();i++)h.assertTrue(!InsectSpawner.supports(level,InsectCatalog.byId(species.get(i)),supports.get(i),i==3?Direction.NORTH:Direction.UP),"Roof rejects natural habitat: "+roof+" "+species.get(i));
h.assertTrue(InsectSpawner.spawn(level,InsectCatalog.byId("long_locust"),new InsectSpawner.Site(ground,Direction.UP,Vec3.atBottomCenterOf(ground.above()).add(0,.02,0)))==null,"Direct spawn cannot bypass the roof guard");
}
} finally {for(int x=-2;x<=2;x++)for(int z=-2;z<=2;z++)level.setBlockAndUpdate(ground.offset(x,4,z),Blocks.AIR.defaultBlockState());}
h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void movingNaturalInsectsCannotEnterAnOpenDoorUnderARoof(GameTestHelper h) {
hoppingGround(h);var level=h.getLevel();var ground=h.absolutePos(new BlockPos(6,4,6));
var species=InsectCatalog.byId("mosquito");var bug=InsectSpawner.spawn(level,species,InsectSpawner.findSite(level,species,ground));
h.assertTrue(bug!=null,"Natural flyer starts outside the shelter");Vec3 origin=bug.position();
try {
for(int x=1;x<=4;x++)for(int z=-2;z<=2;z++)level.setBlockAndUpdate(ground.offset(x,4,z),Blocks.GLASS.defaultBlockState());
bug.scareFrom(origin.add(-2,0,0));for(int t=0;t<23;t++)step(bug);
h.assertTrue(bug.getX()>origin.x && bug.getX()<ground.getX()+1,"Clear flight stops at the roof boundary despite an open doorway");step(bug);
h.assertTrue(bug.isRemoved(),"Blocked flight still disappears normally");
} finally {for(int x=1;x<=4;x++)for(int z=-2;z<=2;z++)level.setBlockAndUpdate(ground.offset(x,4,z),Blocks.AIR.defaultBlockState());bug.discard();}
h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void mixedPopulationLimitsPreserveDiversityAndAmbientPillBugs(GameTestHelper h) {
hoppingGround(h);var level=h.getLevel();var center=h.absoluteVec(new Vec3(6.5,5.02,6.5));
level.getEntitiesOfClass(InsectEntity.class,new AABB(center,center).inflate(32),i->InsectEcology.limited(i.species())).forEach(InsectEntity::discard);
for(var group:List.of(List.of("long_locust","migratory_locust","grasshopper"),List.of("red_dragonfly","darner_dragonfly","banded_dragonfly"),List.of("pill_bug","pill_bug","pill_bug"))) {
var insects=new ArrayList<InsectEntity>();
for(int i=0;i<3;i++) {
var pos=h.absolutePos(new BlockPos(3+i*3,4,6));
if(group.getFirst().equals("pill_bug"))level.setBlockAndUpdate(pos,Blocks.STONE.defaultBlockState());
var species=InsectCatalog.byId(group.get(i));var site=InsectSpawner.findSite(level,species,pos);
h.assertTrue(site!=null,"Population test has a real habitat: "+species.id());
var insect=InsectSpawner.spawn(level,species,site);
h.assertTrue((i<2)==(insect!=null),"Only two local members of a shared population can spawn: "+group+" index="+i);
if(insect!=null)insects.add(insect);
if(i==2) {
insects.getFirst().discard();var replacement=InsectSpawner.spawn(level,species,site);
h.assertTrue(replacement!=null,"Capture/removal frees population space");insects.add(replacement);
}
}
insects.forEach(InsectEntity::discard);
}
var now=fr.koka.sanctuary.realtime.RealtimeService.civilTime(level.getServer());
h.assertTrue(InsectSpawner.ambientSpecies(now,false).contains(InsectCatalog.byId("pill_bug")),"Pill bugs participate in real ambient selection without a rock strike");
var remote=h.absolutePos(new BlockPos(480,4,6));
h.assertTrue(!level.hasChunkAt(remote) && !InsectEcology.outdoors(level,remote) && !InsectSpawner.supports(level,InsectCatalog.byId("grasshopper"),remote,Direction.UP)
&& !level.hasChunkAt(remote),"Ecology cannot load a witness chunk");h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void fullInventoryPreservesCapturedItemAndSpectatorCannotCatch(GameTestHelper h) {
var player=player(h);for(int i=0;i<player.getInventory().getContainerSize();i++)player.getInventory().setItem(i,new ItemStack(Items.STONE,64));
player.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Insects.NET));
var target=insect(h,"ant",player.getEyePosition().add(2,0,0));
h.assertTrue(InsectNetItem.sweep(player,InteractionHand.MAIN_HAND) && target.isRemoved(),"Full inventory still captures");
h.assertTrue(h.getLevel().getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,player.getBoundingBox().inflate(3),
item->item.getItem().is(Insects.specimen(InsectCatalog.byId("ant")))).size()==1,"One protected specimen drops at player feet");
var other=insect(h,"ant",player.getEyePosition().add(2,0,0));
var spectator=Progression003GameTests.player(h,net.minecraft.world.level.GameType.SPECTATOR);spectator.setPos(player.position());
spectator.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Insects.NET));
h.assertTrue(!InsectNetItem.sweep(spectator,InteractionHand.MAIN_HAND) && !other.isRemoved(),"Spectator cannot capture");h.succeed();
}
@GameTest(structure="sanctuary-gametest:insect_outdoors_251",skyAccess=true) public void spawningIsBoundedTransientAndDoesNotLoadChunks(GameTestHelper h) {
var level=h.getLevel();var pos=h.absolutePos(new BlockPos(3,4,3));level.setBlockAndUpdate(pos,Blocks.OAK_LOG.defaultBlockState());level.setBlockAndUpdate(pos.north(),Blocks.AIR.defaultBlockState());
var species=InsectCatalog.byId("cockroach");var site=new InsectSpawner.Site(pos,Direction.NORTH,Vec3.atCenterOf(pos).add(0,0,-.515));
var first=InsectSpawner.spawn(level,species,site);h.assertTrue(first!=null && !first.shouldBeSaved(),"Ambient insect is ephemeral");
h.assertTrue(InsectSpawner.spawn(level,species,site)==null,"Occupied site rejects duplicate ambient population");
var remote=pos.offset(480,0,0);h.assertTrue(!level.hasChunkAt(remote),"Witness chunk starts unloaded");
h.assertTrue(InsectSpawner.findSite(level,species,remote)==null && !level.hasChunkAt(remote),"Habitat sampling cannot force chunks to load");h.succeed();
}
// ServerLevel normally increments this before Entity.tick; manual focused steps must too.
private static void step(InsectEntity insect) {insect.tickCount++;insect.tick();}
private static void hoppingGround(GameTestHelper h) {
try {
var field=fr.koka.sanctuary.realtime.RealtimeService.Session.class.getDeclaredField("clock");field.setAccessible(true);
field.set(fr.koka.sanctuary.realtime.RealtimeService.session(h.getLevel().getServer()),java.time.Clock.fixed(java.time.Instant.parse("2026-09-15T12:00:00Z"),java.time.ZoneOffset.UTC));
} catch(ReflectiveOperationException e) {throw new IllegalStateException(e);}
for(int x=0;x<=13;x++)for(int z=0;z<=13;z++) {
h.getLevel().setBlockAndUpdate(h.absolutePos(new BlockPos(x,4,z)),Blocks.GRASS_BLOCK.defaultBlockState());
for(int y=5;y<=7;y++)h.getLevel().setBlockAndUpdate(h.absolutePos(new BlockPos(x,y,z)),Blocks.AIR.defaultBlockState());
}
}
private static ServerPlayer player(GameTestHelper h) {
var p=Progression003GameTests.player(h);p.setUUID(UUID.randomUUID());p.getInventory().clearContent();
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Insects.NET));p.setPos(h.absoluteVec(new Vec3(1.5,4,3.5)));p.setYRot(-90);p.setXRot(0);return p;
}
private static InsectEntity insect(GameTestHelper h,String id,Vec3 pos) {
var insect=new InsectEntity(Insects.TYPE,h.getLevel());insect.place(InsectCatalog.byId(id),BlockPos.containing(pos).below(),Direction.UP,pos);h.getLevel().addFreshEntity(insect);return insect;
}
}
@@ -0,0 +1,145 @@
package fr.koka.sanctuary.gametest;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.atlas.*;
import fr.koka.sanctuary.familiar.FamiliarExperience;
import fr.koka.sanctuary.waystone.*;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Input;
import net.minecraft.world.level.block.Blocks;
public final class Waystone239GameTests {
@GameTest public void distanceAndPointBudget(GameTestHelper h){
var zero=BlockPos.ZERO;
h.assertTrue(Waystones.cost(zero,zero)==0 && Waystones.cost(zero,new BlockPos(8,0,0))==1
&& Waystones.cost(zero,new BlockPos(9,0,0))==1 && Waystones.cost(zero,new BlockPos(16,0,0))==1
&& Waystones.cost(zero,new BlockPos(17,0,0))==2 && Waystones.cost(zero,new BlockPos(0,32,0))==2
&& Waystones.cost(zero,new BlockPos(3,4,0))==1 && Waystones.cost(zero,new BlockPos(512,0,0))==32,"3D distance rounds up at sixteen blocks");
var p=player(h);p.totalExperience=99999;
h.assertTrue(FamiliarExperience.playerXp(p)==160,"Ten native levels are 160 points, independent of stale lifetime XP");h.succeed();
}
@GameTest public void horizontalRequiresPersonalDiscovery(GameTestHelper h){
var a=stone(h,1,3,1,"A");var b=stone(h,7,3,1,"B");var p=player(h);at(p,a);Waystones.discover(p,a);
h.assertTrue(!Waystones.teleport(p,a,b) && FamiliarExperience.playerXp(p)==160,"Unknown horizontal destination refuses without charge");
h.assertTrue(Waystones.snapshot(p,a).destinations().stream().noneMatch(d->d.pos().equals(b)),"Hidden horizontal stone never crosses network");
at(p,b);Waystones.discover(p,b);at(p,a);
h.assertTrue(Waystones.teleport(p,a,b) && FamiliarExperience.playerXp(p)==159 && p.blockPosition().equals(b.above()),"Visited house charges one point");
h.assertTrue(!Waystones.teleport(p,b,a) && FamiliarExperience.playerXp(p)==159,"Cooldown never charges twice");
var stranger=player(h);at(stranger,a);Waystones.discover(stranger,a);
h.assertTrue(!Waystones.teleport(stranger,a,b),"Discovery remains personal");h.succeed();
}
@GameTest public void unknownFloorsWorkInBothDirections(GameTestHelper h){
var a=stone(h,2,3,2,"RDC");var b=stone(h,2,19,2,"Etage");var low=stone(h,2,-13,2,"Cave");
var up=player(h);at(up,a);Waystones.discover(up,a);
h.assertTrue(Waystones.snapshot(up,a).destinations().stream().filter(WaystonePayloads.Destination::vertical).count()==2,"Both unknown floors are listed");
h.assertTrue(Waystones.teleport(up,a,b) && FamiliarExperience.playerXp(up)==159,"Unknown upper floor costs one point for sixteen blocks");
h.assertTrue(WaystoneDiscoveryData.get(h.getLevel().getServer()).knows(up.getUUID(),h.getLevel(),b),"Arrival discovers the floor");
var down=player(h);at(down,a);Waystones.discover(down,a);
h.assertTrue(Waystones.teleport(down,a,low) && FamiliarExperience.playerXp(down)==159,"Unknown lower floor costs one point for sixteen blocks");h.succeed();
}
@GameTest(maxTicks=80) public void shortcutsUseNearestFloorAndNeverCascade(GameTestHelper h){
var a=stone(h,3,3,3,"RDC");var b=stone(h,3,11,3,"1");var top=stone(h,3,19,3,"2");var p=player(h);at(p,a);
input(p,false,false);Waystones.tickPlayer(p);input(p,true,false);Waystones.tickPlayer(p);
h.assertTrue(p.blockPosition().equals(b.above()) && FamiliarExperience.playerXp(p)==159,"Jump selects nearest unknown upper floor");
h.runAfterDelay(12,()->{
Waystones.tickPlayer(p);h.assertTrue(p.blockPosition().equals(b.above()),"Holding jump cannot cascade");
input(p,false,false);Waystones.tickPlayer(p);input(p,true,false);Waystones.tickPlayer(p);
h.assertTrue(p.blockPosition().equals(top.above()) && FamiliarExperience.playerXp(p)==158,"Repress jump advances one floor");
h.runAfterDelay(12,()->{
input(p,false,false);Waystones.tickPlayer(p);input(p,false,true);Waystones.tickPlayer(p);
h.assertTrue(p.blockPosition().equals(b.above()) && FamiliarExperience.playerXp(p)==157,"Crouch returns to nearest lower floor");
h.runAfterDelay(12,()->{Waystones.tickPlayer(p);h.assertTrue(p.blockPosition().equals(b.above()),"Holding crouch cannot cascade");h.succeed();});
});
});
}
@GameTest public void unsafePoorAndRemoteRequestsDoNotCharge(GameTestHelper h){
var a=stone(h,4,3,4,"Source");var b=stone(h,4,27,4,"Target");var p=player(h);at(p,a);Waystones.discover(p,a);
FamiliarExperience.setPlayerXp(p,1);
h.assertTrue(!Waystones.teleport(p,a,b) && FamiliarExperience.playerXp(p)==1,"Insufficient points leave player and XP unchanged");
FamiliarExperience.setPlayerXp(p,160);
for(int x=-3;x<=3;x++)for(int z=-3;z<=3;z++)for(int y=0;y<=4;y++)if(x!=0 || z!=0 || y!=0)h.getLevel().setBlockAndUpdate(b.offset(x,y,z),Blocks.STONE.defaultBlockState());
h.assertTrue(!Waystones.teleport(p,a,b) && FamiliarExperience.playerXp(p)==160,"Sealed arrival never charges");
p.setPos(a.getX()+40,a.getY()+1,a.getZ());
h.assertTrue(!Waystones.teleport(p,a,b) && !Waystones.rename(p,a,"Remote"),"Remote source and rename refuse");
at(p,a);h.getLevel().setBlockAndUpdate(b,Blocks.AIR.defaultBlockState());
h.assertTrue(!Waystones.teleport(p,a,b) && FamiliarExperience.playerXp(p)==160,"Broken target never charges");h.succeed();
}
@GameTest public void namesIndexDiscoveryAndMarkersRoundTrip(GameTestHelper h){
var a=stone(h,5,3,5,"Maison");var upper=stone(h,5,203,5,"Secret");stone(h,8,3,5,"Hidden");var p=player(h);at(p,a);Waystones.discover(p,a);
h.assertTrue(Waystones.rename(p,a," Chez Alice ") && Waystones.snapshot(p,a).name().equals("Chez Alice"),"In-place rename normalizes spaces");
var data=WaystoneSavedData.get(h.getLevel().getServer());var encoded=WaystoneSavedData.CODEC.encodeStart(JsonOps.INSTANCE,data).getOrThrow();
h.assertTrue(WaystoneSavedData.CODEC.parse(JsonOps.INSTANCE,encoded).getOrThrow().get(h.getLevel(),a).name().equals("Chez Alice"),"Index round trips names and positions");
var discoveries=WaystoneDiscoveryData.get(h.getLevel().getServer());var json=WaystoneDiscoveryData.CODEC.encodeStart(JsonOps.INSTANCE,discoveries).getOrThrow();
h.assertTrue(WaystoneDiscoveryData.CODEC.parse(JsonOps.INSTANCE,json).getOrThrow().knows(p.getUUID(),h.getLevel(),a),"Discoveries round trip");
var markers=Waystones.markers(p,new AtlasPayloads.Request(1,a.getX()>>4,a.getZ()>>4,false));
h.assertTrue(markers.isEmpty(),"An unknown floor hides the entire elevator column");
h.assertTrue(Waystones.markers(p,new AtlasPayloads.Request(1,a.getX()>>4,a.getZ()>>4,false,140)).isEmpty(),"A floor above the altitude cut still identifies an elevator");
h.getLevel().setBlockAndUpdate(upper,Blocks.AIR.defaultBlockState());
markers=Waystones.markers(p,new AtlasPayloads.Request(1,a.getX()>>4,a.getZ()>>4,false));
h.assertTrue(markers.size()==1 && markers.getFirst().name().equals("Chez Alice"),"Map exposes only discovered stones");
h.getLevel().setBlockAndUpdate(a,Blocks.AIR.defaultBlockState());h.assertTrue(Waystones.markers(p,new AtlasPayloads.Request(1,a.getX()>>4,a.getZ()>>4,false)).isEmpty(),"Broken marker disappears");h.succeed();
}
@GameTest public void elevatorAndHorizontalNetworksAreSeparated(GameTestHelper h){
var base=stone(h,6,3,6,"Elevator");var upper=stone(h,6,11,6,"Floor");var house=stone(h,9,3,6,"House");
var other=stone(h,8,3,8,"Other elevator");stone(h,8,11,8,"Other floor");
var p=player(h);at(p,house);Waystones.discover(p,house);at(p,other);Waystones.discover(p,other);at(p,base);Waystones.discover(p,base);
var view=Waystones.snapshot(p,base);
h.assertTrue(view.elevator() && view.destinations().size()==1 && view.destinations().getFirst().pos().equals(upper),"Undiscovered floors define an elevator without horizontal choices");
h.assertTrue(!Waystones.teleport(p,base,house) && !Waystones.teleport(p,base,other) && FamiliarExperience.playerXp(p)==160,"Forged horizontal exits from either elevator refuse without XP debit");
at(p,house);h.assertTrue(Waystones.snapshot(p,house).destinations().isEmpty() && !Waystones.teleport(p,house,base),"Known elevator bases are excluded from horizontal arrivals");
h.getLevel().setBlockAndUpdate(upper,Blocks.AIR.defaultBlockState());at(p,base);
h.assertTrue(!Waystones.snapshot(p,base).elevator() && Waystones.teleport(p,base,house),"Removing the last other floor restores a standalone horizontal stone");h.succeed();
}
@GameTest public void commandsControlPersistentTariffAndActualDebit(GameTestHelper h){
var server=h.getLevel().getServer();var settings=WaystoneSettings.get(server);var original=settings.tariff();
var a=stone(h,7,3,7,"A");var b=stone(h,7,27,7,"B");var p=player(h);at(p,a);Waystones.discover(p,a);
try{
var dispatcher=server.getCommands().getDispatcher();var console=server.createCommandSourceStack();
h.assertTrue(dispatcher.execute("sanctuary waystone cost 2 16",console)==1 && Waystones.cost(server,a,b)==3,"Console changes the XP/distance ratio");
h.assertTrue(Waystones.snapshot(p,a).tariff().equals(new WaystoneTariff(2,16)) && Waystones.snapshot(p,a).destinations().getFirst().cost()==3,"Configured prices and ratio reach the menu");
h.assertTrue(Waystones.teleport(p,a,b) && FamiliarExperience.playerXp(p)==157,"Actual travel debits the configured three points");
var encoded=WaystoneSettings.CODEC.encodeStart(JsonOps.INSTANCE,settings).getOrThrow();
h.assertTrue(WaystoneSettings.CODEC.parse(JsonOps.INSTANCE,encoded).getOrThrow().tariff().equals(settings.tariff()),"Tariff survives SavedData codec round trip");
h.assertTrue(dispatcher.execute("sanctuary waystone cost 0.0625",console)==1 && Waystones.cost(server,a,b)==2,"A direct XP-per-block amount is accepted");
boolean refused=false;try{dispatcher.execute("sanctuary waystone cost 1 0",console);}catch(com.mojang.brigadier.exceptions.CommandSyntaxException expected){refused=true;}
h.assertTrue(refused && Waystones.cost(server,a,b)==2,"Zero block divisor refuses without changing the previous tariff");
h.assertTrue(!WaystoneSettings.canChange(p.createCommandSourceStack()) && WaystoneSettings.change(p.createCommandSourceStack(),0,1)==0,"Ordinary multiplayer players cannot change a server-wide price");
h.assertTrue(dispatcher.execute("sanctuary waystone cost 0",console)==1 && Waystones.cost(server,a,b)==0,"Zero XP explicitly configures free travel");
h.assertTrue(new WaystoneTariff(1_000_000,1).cost(new BlockPos(-30_000_000,0,0),new BlockPos(30_000_000,0,0))==Integer.MAX_VALUE,"High prices cannot overflow into free travel");
}catch(Exception e){throw new AssertionError(e);}finally{settings.set(original);}
h.succeed();
}
@GameTest public void playerMapSnapshotsRespectPermissionDimensionAndViewport(GameTestHelper h){
var viewer=player(h);var near=player(h);var far=player(h);var spectator=Progression003GameTests.player(h,net.minecraft.world.level.GameType.SPECTATOR);
spectator.setUUID(UUID.randomUUID());near.setPos(viewer.getX()+200,viewer.getY(),viewer.getZ());far.setPos(viewer.getX()+2000,viewer.getY(),viewer.getZ());
var server=h.getLevel().getServer();var online=server.getPlayerList().getPlayers();var fixture=List.of(viewer,near,far,spectator);online.addAll(fixture);
var request=new AtlasPayloads.Request(17,viewer.blockPosition().getX()>>4,viewer.blockPosition().getZ()>>4,false);
try{
h.assertTrue(AtlasPlayersPayload.snapshot(viewer,request).players().isEmpty(),"Locked Atlas never exposes player positions");
var record=fr.koka.sanctuary.progression.ProgressRecord.INITIAL.buy("atlas",1);
viewer.setAttached(fr.koka.sanctuary.progression.ProgressionService.RECORD,record.json());
fr.koka.sanctuary.progression.ProgressEffects.apply(viewer,record);
var snapshot=AtlasPlayersPayload.snapshot(viewer,request);
h.assertTrue(snapshot.token()==17 && snapshot.players().stream().map(AtlasPlayersPayload.Player::id).collect(java.util.stream.Collectors.toSet()).equals(Set.of(viewer.getUUID(),near.getUUID())),"Same-dimension online players are included beyond entity tracking, spectators and distant viewports excluded");
var buffer=new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(),h.getLevel().registryAccess());
try{AtlasPlayersPayload.CODEC.encode(buffer,snapshot);h.assertTrue(AtlasPlayersPayload.CODEC.decode(buffer).equals(snapshot),"Player identities and precise positions round trip on the wire");}finally{buffer.release();}
near.setServerLevel(server.getLevel(net.minecraft.world.level.Level.NETHER));
h.assertTrue(AtlasPlayersPayload.snapshot(viewer,request).players().size()==1,"Other dimensions never cross the map connection");
viewer.setAttached(fr.koka.sanctuary.progression.AptitudeService.PREFERENCES,fr.koka.sanctuary.progression.AptitudePreferences.DEFAULT.with("atlas",false).disabledMask());
h.assertTrue(AtlasPlayersPayload.snapshot(viewer,request).players().isEmpty(),"Disabling Atlas revokes position snapshots");
}finally{online.removeAll(fixture);}
h.succeed();
}
private static ServerPlayer player(GameTestHelper h){var p=Progression003GameTests.player(h);p.setUUID(UUID.randomUUID());p.setExperienceLevels(10);return p;}
private static void at(ServerPlayer p,BlockPos at){p.setPos(at.getX()+.5,at.getY()+1,at.getZ()+.5);}
private static void input(ServerPlayer p,boolean jump,boolean crouch){p.setLastClientInput(new Input(false,false,false,false,jump,crouch,false));p.setShiftKeyDown(crouch);}
private static BlockPos stone(GameTestHelper h,int x,int y,int z,String name){
var at=h.absolutePos(new BlockPos(x,y,z));h.getLevel().setBlockAndUpdate(at,WaystoneBlock.BLOCK.defaultBlockState());
h.getLevel().setBlockAndUpdate(at.above(),Blocks.AIR.defaultBlockState());h.getLevel().setBlockAndUpdate(at.above(2),Blocks.AIR.defaultBlockState());
WaystoneSavedData.get(h.getLevel().getServer()).addOrUpdate(h.getLevel(),at,name);return at;
}
}
@@ -0,0 +1,58 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import fr.koka.sanctuarytest.SharedIsland196;
import java.nio.file.*;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
public final class WaystoneLarge239GameTests {
@GameTest(maxTicks=12000) public void tenLevelsPayForRealLargeIslandHouseVisits(GameTestHelper h){
var level=h.getLevel();
h.assertTrue(SharedIsland196.size(level.getChunkSource().getGenerator())==SharedIsland196.Size.LARGE
&& SharedIsland196.revision(level.getChunkSource().getGenerator())==231,"The actual distributed Sanctuary Large profile, not a flat or historical generator, is required");
var scene=new WaystoneLife239(level);var residents=new ArrayList<WaystoneLife239.Resident>();
for(var name:List.of("voisinage","ile","etages")){
var p=Progression003GameTests.player(h);p.setUUID(UUID.randomUUID());
var route=switch(name){
case "voisinage" -> List.of("Alice","Bob","Clara","Centre");
case "ile" -> List.of("Nord","Est","Centre","Sud","Ouest","Centre");
default -> List.of("Centre 1","Centre 2","Ascenseur");
};
residents.add(new WaystoneLife239.Resident(name,p,scene,route));
}
class Run {
void step(){
try{
residents.removeIf(r->{if(r.trips.size()>300)throw new AssertionError("Budget never depleted");return !r.step();});
if(!residents.isEmpty()){h.runAfterDelay(12,this::step);return;}
}catch(Throwable error){scene.release(level);throw error;}
finish();
}
void finish(){h.succeed();}
}
// Keep finished residents for the report while active ones are removed.
var all=List.copyOf(residents);
var run=new Run(){@Override void finish(){
try{
String version=net.fabricmc.loader.api.FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow().getMetadata().getVersion().getFriendlyString();
var report=new LinkedHashMap<String,Object>();report.put("minecraft","26.3");report.put("version",version);report.put("seed",level.getSeed());report.put("preset",SharedIsland196.Size.LARGE.id().toString());report.put("diameter",1024);report.put("xp",fr.koka.sanctuary.waystone.WaystoneSettings.get(level.getServer()).tariff().xp());report.put("blocks_per_xp",fr.koka.sanctuary.waystone.WaystoneSettings.get(level.getServer()).tariff().blocks());report.put("initial_levels",10);report.put("initial_points",160);report.put("houses",scene.houses);
var scenarios=new ArrayList<Map<String,Object>>();
StringBuilder csv=new StringBuilder("scenario,trip,source,target,distance_blocks,cost_xp,before_xp,after_xp,remaining_level,accepted\n");
for(var r:all){
scenarios.add(Map.of("name",r.name,"visits",r.visits(),"total_distance",r.totalDistance,"spent_xp",r.spent,"remaining_xp",fr.koka.sanctuary.familiar.FamiliarExperience.playerXp(r.player),"trips",r.trips));
for(int i=0;i<r.trips.size();i++){var t=r.trips.get(i);csv.append(String.format(Locale.ROOT,"%s,%d,%s,%s,%.3f,%d,%d,%d,%d,%s\n",r.name,i+1,t.source(),t.target(),t.distance(),t.cost(),t.before(),t.after(),t.level(),t.accepted()));}
System.out.println("WAYSTONES239_LARGE "+r.name+": "+r.visits()+" trips / "+r.totalDistance+" blocks / "+r.spent+" XP / remaining "+fr.koka.sanctuary.familiar.FamiliarExperience.playerXp(r.player));
h.assertTrue(r.visits()>0 && r.spent<=160 && !r.trips.getLast().accepted(),"Real trips end at an unaffordable destination");
}
report.put("scenarios",scenarios);var directory=Path.of("diagnostics");Files.createDirectories(directory);
String basename="waystones"+version.substring("beta.".length())+"-large-seed-"+level.getSeed();
Files.writeString(directory.resolve(basename+".json"),new GsonBuilder().setPrettyPrinting().create().toJson(report));
Files.writeString(directory.resolve(basename+".csv"),csv);
scene.release(level);h.succeed();
}catch(Exception e){scene.release(level);throw new AssertionError(e);}
}};
h.runAfterDelay(1,run::step);
}
}
@@ -0,0 +1,71 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.familiar.FamiliarExperience;
import fr.koka.sanctuary.waystone.*;
import java.util.*;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.levelgen.Heightmap;
/** A resident visits real houses on generated terrain; fixture exploration is separate from the measured XP budget. */
public final class WaystoneLife239 {
public final Map<String,BlockPos> houses=new LinkedHashMap<>();
public final Set<Long> forced=new HashSet<>();
public WaystoneLife239(ServerLevel level){
house(level,"Centre",24,-24);house(level,"Alice",48,24);house(level,"Bob",112,24);house(level,"Clara",80,88);
house(level,"Nord",0,-176);house(level,"Est",224,-96);house(level,"Sud",64,256);house(level,"Ouest",-176,64);
var centre=houses.get("Centre").offset(0,0,8);floor(level,"Ascenseur",centre);floor(level,"Centre 1",centre.above(8));floor(level,"Centre 2",centre.above(16));
}
private void house(ServerLevel level,String name,int x,int z){
for(int dx=-1;dx<=1;dx++)for(int dz=-1;dz<=1;dz++){
int cx=(x>>4)+dx,cz=(z>>4)+dz;long key=net.minecraft.world.level.ChunkPos.pack(cx,cz);
if(level.setChunkForced(cx,cz,true))forced.add(key);level.getChunk(cx,cz);
}
int y=level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,x,z)+1;
if(y<30 || y>=640)throw new AssertionError("No house terrain at "+x+", "+z+": "+y);
floor(level,name,new BlockPos(x,y,z));
}
private void floor(ServerLevel level,String name,BlockPos stone){
for(int dx=-2;dx<=2;dx++)for(int dz=-2;dz<=2;dz++){
var base=stone.offset(dx,0,dz);level.setBlockAndUpdate(base,Blocks.OAK_PLANKS.defaultBlockState());
for(int dy=1;dy<=3;dy++)level.setBlockAndUpdate(base.above(dy),Blocks.AIR.defaultBlockState());
if(Math.abs(dx)==2 && Math.abs(dz)==2)for(int dy=1;dy<=3;dy++)level.setBlockAndUpdate(base.above(dy),Blocks.OAK_LOG.defaultBlockState());
level.setBlockAndUpdate(base.above(4),Blocks.OAK_PLANKS.defaultBlockState());
}
level.setBlockAndUpdate(stone,WaystoneBlock.BLOCK.defaultBlockState());
WaystoneSavedData.get(level.getServer()).addOrUpdate(level,stone,name);houses.put(name,stone);
}
public void prepare(ServerPlayer p){
for(var e:houses.entrySet())if(!e.getKey().equals("Centre 1") && !e.getKey().equals("Centre 2")){
at(p,e.getValue());Waystones.discover(p,e.getValue());
}
at(p,houses.get("Centre"));p.setExperienceLevels(10);p.setExperiencePoints(0);p.totalExperience=160;
}
public static void at(ServerPlayer p,BlockPos stone){p.setPos(stone.getX()+.5,stone.getY()+1,stone.getZ()+.5);}
public void release(ServerLevel level){for(long key:forced)level.setChunkForced(net.minecraft.world.level.ChunkPos.getX(key),net.minecraft.world.level.ChunkPos.getZ(key),false);forced.clear();}
public record Trip(String source,String target,double distance,int cost,long before,long after,int level,boolean accepted){}
public static final class Resident {
public final ServerPlayer player;
public final String name;
private final WaystoneLife239 scene;
private final List<String> route;
public final List<Trip> trips=new ArrayList<>();
public String source="Centre";
public double totalDistance;
public int spent;
public Resident(String name,ServerPlayer player,WaystoneLife239 scene,List<String> route){this.name=name;this.player=player;this.scene=scene;this.route=route;scene.prepare(player);if(name.equals("etages")){source="Ascenseur";at(player,scene.houses.get(source));}}
public boolean step(){
String target=route.get(trips.size()%route.size());var from=scene.houses.get(source);var to=scene.houses.get(target);
long before=FamiliarExperience.playerXp(player);int cost=Waystones.cost(player.level().getServer(),from,to);double distance=Waystones.distance(from,to);
boolean accepted=Waystones.teleport(player,from,to);long after=FamiliarExperience.playerXp(player);
trips.add(new Trip(source,target,distance,cost,before,after,player.experienceLevel,accepted));
if(accepted){
if(before-after!=cost || !player.blockPosition().equals(to.above()))throw new AssertionError("Incorrect debit/arrival");
totalDistance+=distance;spent+=cost;source=target;
}else if(before>=cost || before!=after || !player.blockPosition().equals(from.above()))throw new AssertionError("Trip refused for an unexpected reason");
return accepted;
}
public int visits(){return (int)trips.stream().filter(Trip::accepted).count();}
}
}
@@ -0,0 +1,132 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.zipline.*;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.*;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.*;
import net.minecraft.world.item.*;
import net.minecraft.world.item.crafting.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
public final class Zipline242GameTests {
@GameTest public void historicalRecipesCraftActualItems(GameTestHelper h) {
craft(h,Arrays.asList(null,Items.IRON_INGOT,null,Items.IRON_INGOT,Items.STRING,Items.IRON_INGOT,null,Items.IRON_INGOT,null),Ziplines.GRAPPLING_HAND);
craft(h,Arrays.asList(null,null,Items.STICK,null,Items.STICK,Items.STRING,Items.STICK,null,Ziplines.GRAPPLING_HAND),Ziplines.GRAPPLING_ROD);
craft(h,Arrays.asList(null,Items.IRON_BLOCK,null,Items.IRON_BLOCK,Ziplines.GRAPPLING_HAND,Items.IRON_BLOCK,null,Items.IRON_BLOCK,null),Ziplines.PULLEY_ITEM);
h.assertTrue(new ItemStack(Items.STONE_PICKAXE).isCorrectToolForDrops(Ziplines.PULLEY_HEAD.defaultBlockState())
&& !new ItemStack(Items.WOODEN_PICKAXE).isCorrectToolForDrops(Ziplines.PULLEY_HEAD.defaultBlockState()),"Iron pulley has native stone-or-better pickaxe harvest tags");
h.succeed();
}
@GameTest public void fixedLineLinksTravelsBothWaysAndBreaks(GameTestHelper h) {
var p=player(h);var a=pulley(h,1,6,1);var b=pulley(h,6,6,1);var leads=new ItemStack(Items.LEAD,3);
PulleyHeadController.handleLeadUse(h.getLevel(),p,InteractionHand.MAIN_HAND,leads,a);
h.assertTrue(leads.getCount()==3,"Selecting first anchor consumes nothing");
PulleyHeadController.handleLeadUse(h.getLevel(),p,InteractionHand.MAIN_HAND,leads,b);
h.assertTrue(leads.getCount()==2 && entity(h,a).isConnectedTo(b) && entity(h,b).isConnectedTo(a),"One lead connects reciprocal endpoints");
var tag=entity(h,a).saveCustomOnly(h.getLevel().registryAccess());
var loaded=new PulleyHeadBlockEntity(a,Ziplines.PULLEY_HEAD.defaultBlockState());loaded.loadWithComponents(net.minecraft.world.level.storage.TagValueInput.create(net.minecraft.util.ProblemReporter.DISCARDING,h.getLevel().registryAccess(),tag));
h.assertTrue(loaded.isConnectedTo(b),"Connection survives native block-entity serialization");
p.setPos(h.absoluteVec(new Vec3(2.5,5.24,1.5)));
PulleyHeadController.startRide(h.getLevel(),p,a);
h.assertTrue(PulleyHeadController.hasRide(p) && h.getLevel().getBlockState(a).getValue(PulleyHeadBlock.POWERED),"Trip powers pulley");
for(int i=0;i<20;i++)PulleyHeadController.tick(p);
h.assertTrue(!PulleyHeadController.hasRide(p) && p.getX()>a.getX()+3,"Forward trip arrives and releases");
h.assertTrue(!h.getLevel().getBlockState(a).getValue(PulleyHeadBlock.POWERED),"Arrival clears power");
PulleyHeadController.startRide(h.getLevel(),p,b);for(int i=0;i<20;i++)PulleyHeadController.tick(p);
h.assertTrue(!PulleyHeadController.hasRide(p) && p.getX()<a.getX()+2,"Reverse trip arrives");
h.getLevel().setBlockAndUpdate(b,Blocks.AIR.defaultBlockState());
h.assertTrue(entity(h,a).connectedPos().isEmpty(),"Breaking either endpoint clears partner");h.succeed();
}
@GameTest public void fixedLineObstacleDropsRider(GameTestHelper h) {
var p=player(h);var a=pulley(h,1,6,2);var b=pulley(h,6,6,2);var lead=new ItemStack(Items.LEAD);
PulleyHeadController.handleLeadUse(h.getLevel(),p,InteractionHand.MAIN_HAND,lead,a);
PulleyHeadController.handleLeadUse(h.getLevel(),p,InteractionHand.MAIN_HAND,lead,b);
p.setPos(h.absoluteVec(new Vec3(2.5,5.24,2.5)));PulleyHeadController.startRide(h.getLevel(),p,a);
h.getLevel().setBlockAndUpdate(h.absolutePos(new BlockPos(4,5,2)),Blocks.STONE.defaultBlockState());
for(int i=0;i<20 && PulleyHeadController.hasRide(p);i++)PulleyHeadController.tick(p);
h.assertTrue(!PulleyHeadController.hasRide(p) && p.getX()<h.absolutePos(new BlockPos(4,5,2)).getX()
&& p.getDeltaMovement().y<0,"Whole rider hits obstacle and falls before passing through it");h.succeed();
}
@GameTest public void unloadedEndpointIsPreservedWithoutForcingTerrain(GameTestHelper h) {
var p=player(h);var a=pulley(h,1,6,5);var unloaded=a.offset(480,0,0);
h.assertTrue(!h.getLevel().hasChunkAt(unloaded),"Remote endpoint begins unloaded");
entity(h,a).setConnectedPos(unloaded);PulleyHeadController.startRide(h.getLevel(),p,a);
h.assertTrue(entity(h,a).isConnectedTo(unloaded) && !PulleyHeadController.hasRide(p)
&& !h.getLevel().hasChunkAt(unloaded),"Unloaded destination refuses travel without erasing the saved link or loading terrain");
h.getLevel().setBlockAndUpdate(a,Blocks.AIR.defaultBlockState());
h.assertTrue(!h.getLevel().hasChunkAt(unloaded),"Breaking source cannot force the remote chunk to load");h.succeed();
}
@GameTest public void chargedThrowConsumesOnceAndCanCancel(GameTestHelper h) {
var p=player(h);var rod=p.getMainHandItem();
h.assertTrue(!PortableZipline.launch(p,InteractionHand.MAIN_HAND,rod,1) && rod.getDamageValue()==0,"Without lead launch refuses without wear");
p.setItemInHand(InteractionHand.OFF_HAND,new ItemStack(Items.LEAD,2));
h.assertTrue(PortableZipline.launch(p,InteractionHand.MAIN_HAND,rod,1),"Offhand lead is accepted");
h.assertTrue(p.getOffhandItem().getCount()==1 && rod.getDamageValue()==1,"One lead and one durability per launch");
h.assertTrue(!PortableZipline.launch(p,InteractionHand.MAIN_HAND,rod,1) && p.getOffhandItem().getCount()==1,"Duplicate launch consumes nothing");
var hook=hook(h,p);h.assertTrue(Math.abs(hook.getDeltaMovement().length()-4.45)<.001,"Full charge keeps historical speed");
((GrapplingRodItem)Ziplines.GRAPPLING_ROD).use(h.getLevel(),p,InteractionHand.MAIN_HAND);
h.assertTrue(!PortableZipline.hasActive(p) && hook.isRemoved() && p.getOffhandItem().getCount()==1,"Second click discards cable without refund");
h.assertTrue(GrapplingRodItem.power(0)==.18F && GrapplingRodItem.power(24)==1F,"Historical 24-tick charge is retained");h.succeed();
}
@GameTest public void portableProjectileAnchorsThenArrivesWithoutSwing(GameTestHelper h) {
var p=player(h);anchorWall(h,6,3);
p.getInventory().setItem(1,new ItemStack(Items.LEAD,2));
h.assertTrue(PortableZipline.launch(p,InteractionHand.MAIN_HAND,p.getMainHandItem(),1),"Launch succeeds");
var hook=hook(h,p);for(int i=0;i<10 && !hook.isAnchored();i++)hook.tick();
h.assertTrue(hook.isAnchored(),"Native projectile ray hits anchor wall");
Vec3 cableStart=hook.cableStart(), initial=p.position();double previous=p.getX();
for(int i=0;i<30 && PortableZipline.hasActive(p);i++) {
PortableZipline.tick(p);h.assertTrue(p.getX()>=previous,"Travel progresses toward anchor without pendulum");previous=p.getX();
h.assertTrue(hook.cableStart().distanceTo(cableStart)<1E-6,"Cable departure stays fixed during trip");
}
h.assertTrue(!PortableZipline.hasActive(p) && hook.isRemoved() && p.getX()>initial.x+2,"Arrival removes temporary cable and hook");
h.assertTrue(p.getInventory().getItem(1).getCount()==1 && !p.isNoGravity(),"Lead is not returned; native gravity remains enabled");h.succeed();
}
@GameTest public void bodyObstacleDropsPortableRider(GameTestHelper h) {
var p=player(h);anchorWall(h,6,3);p.getInventory().setItem(1,new ItemStack(Items.LEAD));
PortableZipline.launch(p,InteractionHand.MAIN_HAND,p.getMainHandItem(),1);var hook=hook(h,p);
for(int i=0;i<10 && !hook.isAnchored();i++)hook.tick();h.assertTrue(hook.isAnchored(),"Hook flies above rider obstacle");
var obstacle=h.absolutePos(new BlockPos(3,4,3));h.getLevel().setBlockAndUpdate(obstacle,Blocks.STONE.defaultBlockState());
for(int i=0;i<20 && PortableZipline.hasActive(p);i++)PortableZipline.tick(p);
h.assertTrue(!PortableZipline.hasActive(p) && hook.isRemoved() && p.getX()<obstacle.getX()
&& p.getDeltaMovement().y<=-.18 && !p.isNoGravity(),"Body collision cuts cable immediately and initiates fall");h.succeed();
}
@GameTest public void missedHookExpiresAndAnchorRemovalReleases(GameTestHelper h) {
var p=player(h);p.getInventory().setItem(1,new ItemStack(Items.LEAD,3));
PortableZipline.launch(p,InteractionHand.MAIN_HAND,p.getMainHandItem(),0);var missed=hook(h,p);
missed.setPos(p.position().add(0,20,0));for(int i=0;i<101 && !missed.isRemoved();i++){missed.setDeltaMovement(new Vec3(0,1,0));missed.tick();}
PortableZipline.tick(p);h.assertTrue(missed.isRemoved() && !PortableZipline.hasActive(p),"Unanchored projectile expires without leaving a session");
anchorWall(h,6,3);PortableZipline.launch(p,InteractionHand.MAIN_HAND,p.getMainHandItem(),1);var caught=hook(h,p);
for(int i=0;i<10 && !caught.isAnchored();i++)caught.tick();h.assertTrue(caught.isAnchored(),"Second projectile anchors");
h.getLevel().setBlockAndUpdate(net.minecraft.core.BlockPos.containing(caught.position().add(.01,0,0)),Blocks.AIR.defaultBlockState());
PortableZipline.tick(p);h.assertTrue(!PortableZipline.hasActive(p) && caught.isRemoved(),"Removing anchor releases rider");h.succeed();
}
@GameTest public void sneakAndHandChangeRemoveTemporaryCable(GameTestHelper h) {
var p=player(h);p.getInventory().setItem(1,new ItemStack(Items.LEAD,2));
PortableZipline.launch(p,InteractionHand.MAIN_HAND,p.getMainHandItem(),1);var hook=hook(h,p);p.setShiftKeyDown(true);PortableZipline.tick(p);
h.assertTrue(hook.isRemoved() && !PortableZipline.hasActive(p),"Sneak releases even during flight");
p.setShiftKeyDown(false);PortableZipline.launch(p,InteractionHand.MAIN_HAND,p.getMainHandItem(),1);hook=hook(h,p);
p.setItemInHand(InteractionHand.MAIN_HAND,ItemStack.EMPTY);PortableZipline.tick(p);
h.assertTrue(hook.isRemoved() && !PortableZipline.hasActive(p),"Putting rod away removes the transient cable");h.succeed();
}
private static ServerPlayer player(GameTestHelper h) {
var p=Progression003GameTests.player(h);p.setUUID(UUID.randomUUID());p.getInventory().clearContent();
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Ziplines.GRAPPLING_ROD));p.setPos(h.absoluteVec(new Vec3(1.5,4.5,3.5)));p.setYRot(-90);p.setXRot(0);return p;
}
private static GrapplingHookEntity hook(GameTestHelper h,ServerPlayer p) {
return h.getLevel().getEntities(p,p.getBoundingBox().inflate(160),e->e instanceof GrapplingHookEntity g && g.getOwner()==p)
.stream().map(GrapplingHookEntity.class::cast).findFirst().orElseThrow();
}
private static void anchorWall(GameTestHelper h,int x,int z) { for(int y=3;y<=8;y++)h.getLevel().setBlockAndUpdate(h.absolutePos(new BlockPos(x,y,z)),Blocks.STONE.defaultBlockState()); }
private static BlockPos pulley(GameTestHelper h,int x,int y,int z) { var at=h.absolutePos(new BlockPos(x,y,z));h.getLevel().setBlockAndUpdate(at,Ziplines.PULLEY_HEAD.defaultBlockState());return at; }
private static PulleyHeadBlockEntity entity(GameTestHelper h,BlockPos at) { return (PulleyHeadBlockEntity)h.getLevel().getBlockEntity(at); }
private static void craft(GameTestHelper h,List<Item> ingredients,Item expected) {
var input=CraftingInput.of(3,3,ingredients.stream().map(i->i==null?ItemStack.EMPTY:new ItemStack(i)).toList());
var recipe=h.getLevel().getServer().getRecipeManager().getRecipeFor(RecipeType.CRAFTING,input,h.getLevel()).orElseThrow();
h.assertTrue(recipe.value().assemble(input).is(expected),"Historical shaped recipe produces "+expected);
}
}
@@ -21,7 +21,7 @@ public abstract class SanctuaryGameTestServerMixin {
// generation test's deadline before its IO/workers can complete. This is test-only pacing.
@Inject(method = "waitUntilNextTick", at = @At("RETURN"))
private void sanctuary$paceExpansionWorkers(CallbackInfo ci) {
if (Boolean.getBoolean("sanctuary.test.aerial24.flowAdmission") || Boolean.getBoolean("sanctuary.test.aerial24") || Boolean.getBoolean("sanctuary.test.admission23") || Boolean.getBoolean("sanctuary.test.ocean23") || Boolean.getBoolean("sanctuary.test.secrets23") || Boolean.getBoolean("sanctuary.test.native23") || Boolean.getBoolean("sanctuary.test.selective22") || (Boolean.getBoolean("sanctuary.test.heritage20") || Boolean.getBoolean("sanctuary.test.heritage21") || Boolean.getBoolean("sanctuary.test.lostCity21") || Boolean.getBoolean("sanctuary.test.transit21")) || Boolean.getBoolean("sanctuary.test.lostCity20")
if (Boolean.getBoolean("sanctuary.test.waystonesLarge239") || Boolean.getBoolean("sanctuary.test.aerial24.flowAdmission") || Boolean.getBoolean("sanctuary.test.aerial24") || Boolean.getBoolean("sanctuary.test.admission23") || Boolean.getBoolean("sanctuary.test.ocean23") || Boolean.getBoolean("sanctuary.test.secrets23") || Boolean.getBoolean("sanctuary.test.native23") || Boolean.getBoolean("sanctuary.test.selective22") || (Boolean.getBoolean("sanctuary.test.heritage20") || Boolean.getBoolean("sanctuary.test.heritage21") || Boolean.getBoolean("sanctuary.test.lostCity21") || Boolean.getBoolean("sanctuary.test.transit21")) || Boolean.getBoolean("sanctuary.test.lostCity20")
|| Boolean.getBoolean("sanctuary.test.heritage19") || Boolean.getBoolean("sanctuary.test.ruins17") || Boolean.getBoolean("sanctuary.test.expansion") || Boolean.getBoolean("sanctuary.test.lostCity")
|| Boolean.getBoolean("sanctuary.test.lostCity16") || Boolean.getBoolean("sanctuary.test.lostCity15") || Boolean.getBoolean("sanctuary.test.voidReuse")) {
long until = System.nanoTime() + 20_000_000L;
@@ -46,6 +46,7 @@ public abstract class SanctuaryGameTestServerMixin {
@Redirect(method = "lambda$create$1", at = @At(value = "FIELD", target =
"Lnet/minecraft/world/level/levelgen/presets/WorldPresets;FLAT_ALL_DIMENSIONS:Lnet/minecraft/resources/ResourceKey;"))
private static ResourceKey<WorldPreset> sanctuary$selectActualPreset() {
if (Boolean.getBoolean("sanctuary.test.waystonesLarge239")) return ResourceKey.create(Registries.WORLD_PRESET, fr.koka.sanctuarytest.SharedIsland196.Size.LARGE.id());
if (Boolean.getBoolean("sanctuary.test.atlasOnly")) return net.minecraft.world.level.levelgen.presets.WorldPresets.FLAT_ALL_DIMENSIONS;
if (Boolean.getBoolean("sanctuary.test.unified")) {
int generation = Integer.getInteger("sanctuary.test.generation", 24);
@@ -69,7 +70,7 @@ public abstract class SanctuaryGameTestServerMixin {
private static net.minecraft.world.level.levelgen.WorldDimensions sanctuary$customizeSize(WorldPreset preset,
net.minecraft.world.level.LevelSettings settings, net.minecraft.server.WorldLoader.DataLoadContext context) {
var dimensions = preset.createWorldDimensions();
if (Boolean.getBoolean("sanctuary.test.unified") && !Boolean.getBoolean("sanctuary.test.atlasOnly")) {
if (Boolean.getBoolean("sanctuary.test.unified") && !Boolean.getBoolean("sanctuary.test.atlasOnly") && !Boolean.getBoolean("sanctuary.test.waystonesLarge239")) {
var generator = (fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator) dimensions.overworld();
return dimensions.replaceOverworldGenerator(context.datapackWorldRegistries(),
generator.withInitialDiameter(Integer.getInteger("sanctuary.test.diameter", 724))
@@ -1,5 +1,6 @@
package fr.koka.sanctuary.gametest.mixin;
import fr.koka.sanctuary.gametest.Water144Metrics;
import fr.koka.sanctuary.gametest.Frame256Metrics;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
@@ -7,6 +8,6 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(Minecraft.class)
abstract class Water144FrameProbeMixin {
@Unique private long sanctuary$start;
@Inject(method="renderFrame",at=@At("HEAD")) private void start(boolean tick,CallbackInfo ci){if(Water144Metrics.recording)sanctuary$start=System.nanoTime();}
@Inject(method="renderFrame",at=@At("HEAD")) private void start(boolean tick,CallbackInfo ci){long now=System.nanoTime();Frame256Metrics.beginFrame(now);if(Water144Metrics.recording)sanctuary$start=now;}
@Inject(method="renderFrame",at=@At("RETURN")) private void end(boolean tick,CallbackInfo ci){if(Water144Metrics.recording)Water144Metrics.frame(System.nanoTime()-sanctuary$start);}
}
@@ -8,7 +8,7 @@
"entrypoints": {
"client": ["fr.koka.sanctuary.gametest.Starter039TestSupport"],
"preLaunch": ["fr.koka.sanctuary.gametest.Inventory010MixinAudit"],
"fabric-gametest": ["fr.koka.sanctuary.gametest.Diagonal231GameTests", "fr.koka.sanctuary.gametest.Diagonal230GameTests", "fr.koka.sanctuary.gametest.Diagonal229GameTests", "fr.koka.sanctuary.gametest.Diagonal228GameTests", "fr.koka.sanctuary.gametest.Diagonal227GameTests", "fr.koka.sanctuary.gametest.Diagonal226GameTests", "fr.koka.sanctuary.gametest.Diagonal225GameTests", "fr.koka.sanctuary.gametest.East224GameTests", "fr.koka.sanctuary.gametest.East223GameTests", "fr.koka.sanctuary.gametest.YellowRock222GameTests", "fr.koka.sanctuary.gametest.West221GameTests", "fr.koka.sanctuary.gametest.West220GameTests", "fr.koka.sanctuary.gametest.West219GameTests", "fr.koka.sanctuary.gametest.West218GameTests", "fr.koka.sanctuary.gametest.West217GameTests", "fr.koka.sanctuary.gametest.Base204GameTests", "fr.koka.sanctuary.gametest.North205GameTests", "fr.koka.sanctuary.gametest.Palace173GameTests", "fr.koka.sanctuary.gametest.Plans072GameTests", "fr.koka.sanctuary.gametest.Starter052GameTests", "fr.koka.sanctuary.gametest.Dispenser051GameTests", "fr.koka.sanctuary.gametest.Head050GameTests", "fr.koka.sanctuary.gametest.Poultry048GameTests", "fr.koka.sanctuary.gametest.StarInspection047GameTests", "fr.koka.sanctuary.gametest.SkyFlight046GameTests", "fr.koka.sanctuary.gametest.GravesFood038GameTests","fr.koka.sanctuary.gametest.Realtime037GameTests","fr.koka.sanctuary.gametest.Corrections036GameTests","fr.koka.sanctuary.gametest.Collections035GameTests","fr.koka.sanctuary.gametest.Notifications034GameTests","fr.koka.sanctuary.gametest.Menus033GameTests","${gametest_entrypoint}", "fr.koka.sanctuary.gametest.Swamp27WorldGameTests", "fr.koka.sanctuary.gametest.Caves28WorldGameTests", "fr.koka.sanctuary.gametest.Terraces29WorldGameTests", "fr.koka.sanctuary.gametest.River30WorldGameTests", "fr.koka.sanctuary.gametest.Transit29WorldGameTests", "fr.koka.sanctuary.gametest.Alpha28NativeStructuresGameTests", "fr.koka.sanctuary.gametest.Village306GameTests", "fr.koka.sanctuary.gametest.Drained307GameTests", "fr.koka.sanctuary.gametest.BlockKnowledgeGameTests", "fr.koka.sanctuary.gametest.CensusGameTestsTerrain", "fr.koka.sanctuary.gametest.CensusStockGameTests", "fr.koka.sanctuary.gametest.MaterialActivityGameTests", "fr.koka.sanctuary.gametest.AncientExpeditions001GameTests", "fr.koka.sanctuary.gametest.Progression003GameTests", "fr.koka.sanctuary.gametest.Atlas005GameTests", "fr.koka.sanctuary.gametest.Demeure006GameTests", "fr.koka.sanctuary.gametest.Mining008GameTests", "fr.koka.sanctuary.gametest.Building009GameTests", "fr.koka.sanctuary.gametest.Inventory010GameTests", "fr.koka.sanctuary.gametest.Cycle011GameTests", "fr.koka.sanctuary.gametest.Inventory012GameTests", "fr.koka.sanctuary.gametest.Inventory014GameTests", "fr.koka.sanctuary.gametest.Tutorial016GameTests", "fr.koka.sanctuary.gametest.Movement017GameTests", "fr.koka.sanctuary.gametest.Accessory018GameTests", "fr.koka.sanctuary.gametest.Companion019GameTests", "fr.koka.sanctuary.gametest.Companion032GameTests", "fr.koka.sanctuary.gametest.Recipes024GameTests", "fr.koka.sanctuary.gametest.Carry027GameTests", "fr.koka.sanctuary.gametest.Familiar029GameTests", "fr.koka.sanctuary.gametest.Dynamic030GameTests", "fr.koka.sanctuary.gametest.GraveInventory039GameTests", "fr.koka.sanctuary.gametest.FoodKnowledge039GameTests", "fr.koka.sanctuary.gametest.OperatorStars040GameTests", "fr.koka.sanctuary.gametest.Starter039GameTests", "fr.koka.sanctuary.gametest.Horde167GameTests", "fr.koka.sanctuary.gametest.Horde168GameTests", "fr.koka.sanctuary.gametest.Horde169GameTests", "fr.koka.sanctuary.gametest.Horde170GameTests", "fr.koka.sanctuary.gametest.Horde171GameTests", "fr.koka.sanctuary.gametest.Horde172GameTests"],
"fabric-gametest": ["fr.koka.sanctuary.gametest.Insect245GameTests","fr.koka.sanctuary.gametest.Forgerie238GameTests","fr.koka.sanctuary.gametest.Aptitudes236GameTests","fr.koka.sanctuary.gametest.Diagonal231GameTests", "fr.koka.sanctuary.gametest.Diagonal230GameTests", "fr.koka.sanctuary.gametest.Diagonal229GameTests", "fr.koka.sanctuary.gametest.Diagonal228GameTests", "fr.koka.sanctuary.gametest.Diagonal227GameTests", "fr.koka.sanctuary.gametest.Diagonal226GameTests", "fr.koka.sanctuary.gametest.Diagonal225GameTests", "fr.koka.sanctuary.gametest.East224GameTests", "fr.koka.sanctuary.gametest.East223GameTests", "fr.koka.sanctuary.gametest.YellowRock222GameTests", "fr.koka.sanctuary.gametest.West221GameTests", "fr.koka.sanctuary.gametest.West220GameTests", "fr.koka.sanctuary.gametest.West219GameTests", "fr.koka.sanctuary.gametest.West218GameTests", "fr.koka.sanctuary.gametest.West217GameTests", "fr.koka.sanctuary.gametest.Base204GameTests", "fr.koka.sanctuary.gametest.North205GameTests", "fr.koka.sanctuary.gametest.Palace173GameTests", "fr.koka.sanctuary.gametest.Plans072GameTests", "fr.koka.sanctuary.gametest.Starter052GameTests", "fr.koka.sanctuary.gametest.Dispenser051GameTests", "fr.koka.sanctuary.gametest.Head050GameTests", "fr.koka.sanctuary.gametest.Poultry048GameTests", "fr.koka.sanctuary.gametest.StarInspection047GameTests", "fr.koka.sanctuary.gametest.SkyFlight046GameTests", "fr.koka.sanctuary.gametest.GravesFood038GameTests","fr.koka.sanctuary.gametest.Realtime037GameTests","fr.koka.sanctuary.gametest.Corrections036GameTests","fr.koka.sanctuary.gametest.Collections035GameTests","fr.koka.sanctuary.gametest.Notifications034GameTests","fr.koka.sanctuary.gametest.Menus033GameTests","${gametest_entrypoint}", "fr.koka.sanctuary.gametest.Swamp27WorldGameTests", "fr.koka.sanctuary.gametest.Caves28WorldGameTests", "fr.koka.sanctuary.gametest.Terraces29WorldGameTests", "fr.koka.sanctuary.gametest.River30WorldGameTests", "fr.koka.sanctuary.gametest.Transit29WorldGameTests", "fr.koka.sanctuary.gametest.Alpha28NativeStructuresGameTests", "fr.koka.sanctuary.gametest.Village306GameTests", "fr.koka.sanctuary.gametest.Drained307GameTests", "fr.koka.sanctuary.gametest.BlockKnowledgeGameTests", "fr.koka.sanctuary.gametest.CensusGameTestsTerrain", "fr.koka.sanctuary.gametest.CensusStockGameTests", "fr.koka.sanctuary.gametest.MaterialActivityGameTests", "fr.koka.sanctuary.gametest.AncientExpeditions001GameTests", "fr.koka.sanctuary.gametest.Progression003GameTests", "fr.koka.sanctuary.gametest.Atlas005GameTests", "fr.koka.sanctuary.gametest.Demeure006GameTests", "fr.koka.sanctuary.gametest.Mining008GameTests", "fr.koka.sanctuary.gametest.Building009GameTests", "fr.koka.sanctuary.gametest.Inventory010GameTests", "fr.koka.sanctuary.gametest.Cycle011GameTests", "fr.koka.sanctuary.gametest.Inventory012GameTests", "fr.koka.sanctuary.gametest.Inventory014GameTests", "fr.koka.sanctuary.gametest.Tutorial016GameTests", "fr.koka.sanctuary.gametest.Movement017GameTests", "fr.koka.sanctuary.gametest.Accessory018GameTests", "fr.koka.sanctuary.gametest.Companion019GameTests", "fr.koka.sanctuary.gametest.Companion032GameTests", "fr.koka.sanctuary.gametest.Recipes024GameTests", "fr.koka.sanctuary.gametest.Carry027GameTests", "fr.koka.sanctuary.gametest.Familiar029GameTests", "fr.koka.sanctuary.gametest.Dynamic030GameTests", "fr.koka.sanctuary.gametest.GraveInventory039GameTests", "fr.koka.sanctuary.gametest.FoodKnowledge039GameTests", "fr.koka.sanctuary.gametest.OperatorStars040GameTests", "fr.koka.sanctuary.gametest.Starter039GameTests", "fr.koka.sanctuary.gametest.Horde167GameTests", "fr.koka.sanctuary.gametest.Horde168GameTests", "fr.koka.sanctuary.gametest.Horde169GameTests", "fr.koka.sanctuary.gametest.Horde170GameTests", "fr.koka.sanctuary.gametest.Horde171GameTests", "fr.koka.sanctuary.gametest.Horde172GameTests", "fr.koka.sanctuary.gametest.Waystone239GameTests"],
"fabric-client-gametest": ["fr.koka.sanctuary.gametest.SanctuaryClientRenderTests"]
},
"mixins": ["sanctuary-gametest.mixins.json"],
@@ -43,6 +43,9 @@ public final class SanctuaryMod implements ModInitializer {
@Override
public void onInitialize() {
fr.koka.sanctuary.insects.Insects.register();
fr.koka.sanctuary.waystone.Waystones.register();
fr.koka.sanctuary.zipline.Ziplines.register();
fr.koka.sanctuary.palace.OriginBlock.register();
fr.koka.sanctuary.palace.ExpansionAnchorBlock.register();
fr.koka.sanctuary.community.CommunityNetwork.register();
@@ -4,7 +4,7 @@ package fr.koka.sanctuary.atlas;
public record AtlasMarker(String id,int x,Integer y,int z,String kind,String sprite,String name) {
public AtlasMarker {
if (id==null || id.isBlank() || id.length()>200 || Math.abs((long)x)>30000000 || Math.abs((long)z)>30000000
|| y!=null&&(y< -2048||y>2048) || !java.util.Set.of("banner","treasure","point").contains(kind)
|| y!=null&&(y< -2048||y>2048) || !java.util.Set.of("banner","treasure","point","waystone").contains(kind)
|| sprite==null || !sprite.matches("[a-z0-9_.-]+:[a-z0-9_./-]+") || sprite.length()>160
|| kind.equals("banner")&&y==null || name==null || name.length()>96) throw new IllegalArgumentException("Atlas marker");
}
@@ -8,7 +8,7 @@ import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
/** Only authorized, viewport-filtered markers cross the connection. */
public record AtlasMarkerPayload(int token,List<AtlasMarker> markers) implements CustomPacketPayload {
public static final int LIMIT=AtlasMarkerStore.LIMIT+16;
public static final int LIMIT=AtlasMarkerStore.LIMIT+16+fr.koka.sanctuary.waystone.WaystoneSavedData.MAX_ENTRIES;
public static final Type<AtlasMarkerPayload> TYPE=new Type<>(SanctuaryMod.id("atlas_markers_v1"));
public static final StreamCodec<RegistryFriendlyByteBuf,AtlasMarkerPayload> CODEC=CustomPacketPayload.codec((p,b)->{
b.writeInt(p.token);b.writeVarInt(p.markers.size());for(var m:p.markers){b.writeUtf(m.id(),200);b.writeInt(m.x());b.writeBoolean(m.y()!=null);if(m.y()!=null)b.writeInt(m.y());b.writeInt(m.z());b.writeUtf(m.kind(),16);b.writeUtf(m.sprite(),160);b.writeUtf(m.name(),96);}
@@ -101,6 +101,7 @@ public final class AtlasMarkers {
save(s);
}
if(request.operator())for(var site:treasureSites(p.level())){var marker=treasure(site);if(marker.inView(request))out.put(marker.id(),marker);}
for(var marker:fr.koka.sanctuary.waystone.Waystones.markers(p,request))out.put(marker.id(),marker);
return new AtlasMarkerPayload(request.token(),List.copyOf(out.values()));
}
}

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