Compare commits

...
Author SHA1 Message Date
koka 235cf51d6e Document Sanctuary Web alignment with the Beta database
Build Sanctuary / build (push) Waiting to run
2026-10-25 05:09:39 +01:00
koka 17c1cd9e71 Verify permanent Beta route and retire UPnP fallback
Build Sanctuary / build (push) Waiting to run
2026-10-25 04:51:51 +01:00
koka 276c8f2144 Record Beta server deployment, web connections and remaining Bbox target
Build Sanctuary / build (push) Waiting to run
2026-10-25 04:44:08 +01:00
koka 23b295356b Embed JEI and add a managed dedicated server pack (beta.280)
Build Sanctuary / build (push) Waiting to run
2026-10-25 04:17:18 +01:00
koka 697e4a205f Add inhabited claims, resident menus and consented Battle transfers (beta.278)
Build Sanctuary / build (push) Waiting to run
Reshape Inhabitant and Pause around the unlocked Atlas, add chunk ownership with Demeure influence priority, and draw connected ownership/protection contours.

Add eight-sapphire three-day protection and explicit two-player Battle stakes with durable, idempotent claim settlement. Preserve existing claims, protection periods and save schemas.

Validated check build assemblePack with 40 targeted server tests, 26 duel-journal checks, and Vulkan FR/EN GUI 2/3/4 flows. Verified the local beta.278 MRpack; generated artifacts remain untracked.
2026-10-25 03:48:01 +01:00
koka c51e611f4f Add Providence prayers and six coloured outcomes (beta.269)
Build Sanctuary / build (push) Waiting to run
Implement fourteen server-authoritative prayers with delayed replies, deferred miracles, alternative aid and verdict colours in SGA and readable text. Document configuration and validation for beta.265 through beta.269.

Validation: check build assemblePack, 191 assertions, 22 server GameTests and Vulkan client checks.
2026-10-24 10:02:45 +02:00
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
249 changed files with 17084 additions and 600 deletions
+97
View File
@@ -1,3 +1,100 @@
# beta.278 — Duel Battle pour un claim consenti
- Proposition depuis le clic droit d’un claim déjà revendiqué ; propriétaire et challenger valident le même enjeu.
- Challenger ayant habité le chunk, propriétaire connecté, Carte active et disponibilité Battle contrôlés côté serveur.
- Battle privé à deux joueurs : consentement sur le claim, puis confirmations Prêt natives ; aucun tiers admis.
- Transfert du seul claim au vainqueur, avec l’échéance de protection conservée et actualisation de la carte/minage.
- Refus, annulation avant lancement, égalité et interruption sans résultat durable ne transfèrent rien.
- Journal additif de consentement/résultat, application atomique et reprise idempotente ; anciens formats conservés.
- Interface de lecture et acceptation FR/EN, enjeu explicite dans Battle, navigation corrigée entre propositions.
- [Contrat et vérifications](docs/claim-battle-beta278.md).
# beta.277 — Claims liés à Demeure
- Habitation Demeure obligatoire avant de revendiquer un chunk libre ; simple traversée insuffisante.
- Priorité à l’habitant le plus influent, avec départage par l’ordre des premières traces en cas d’égalité.
- Un habitant plus influent peut prendre le spot tant qu’il n’a pas été revendiqué ; aucun transfert automatique après acquisition.
- Vérification serveur avant réservation ou débit, et sauvegarde des traces avant achat.
- Carte : condition expliquée, nom/couleur du joueur prioritaire, action bloquée quand nécessaire et actualisation du terrain libre.
- Claims antérieurs, protections, tarifs et formats de sauvegarde conservés ; aucun délai supplémentaire ni duel ajouté.
- [Règles et vérifications](docs/claim-demeure-beta277.md).
# beta.276 — Contours CTM et nouvelle protection des claims
- Propriétés sans protection visibles par leur contour, aux couleurs de leur propriétaire.
- Contours raccordés sur les côtés et angles, sans frontières entre chunks d’un même joueur.
- Fond coloré sur les chunks protégés ; contour conservé après expiration.
- Actualisation des contours voisins après un achat, dans l’Atlas et l’aperçu de Pause ; masquage conservé.
- Protection : 8 saphirs par chunk pour trois jours de serveur en marche ; renouvellements ajoutés au temps restant.
- Durées déjà achetées et reçus de sept jours en attente conservés, sans changement de schéma.
- Libellés FR/EN et légende incluant aussi les propriétés sans protection.
- [Contrat et vérifications](docs/claim-contours-beta276.md).
# beta.275 — Habitant et menu Pause centrés sur l’Atlas
- Retrait du conseil d’arrivée « première propriété gratuite » et du bouton Propriété d’Habitant.
- Propriétés verrouillées côté serveur et client avant l’aptitude Carte achetée et active ; ancien accès carte locale bloqué.
- Habitant : statistiques sans fond à gauche, personnage centré, biographie à droite ; navigation Factions / Familier.
- Activité et Historique retirés de cette fiche ; données conservées.
- Durée de jeu en jours, heures, minutes et secondes entières, sans minutes décimales.
- Pause sans Carte conservée ; avec Carte, Gazette et Tableau dans leurs colonnes latérales, carte au centre.
- Aventure à gauche et Communauté à droite au-dessus de la carte, y compris en grande vue ; Partie et ses actions natives dessous.
- Mise à jour automatique de Pause lors du déblocage ou de la désactivation de Carte ; droits et protections acquis conservés.
- [Contrat et vérifications](docs/menu-atlas-beta275.md).
# beta.274 — Calques de territoire continus
- Fiche Habitant conservée et claims actuels accessibles, selon la clarification du créateur.
- Refonte des règles de propriété, habitation préalable, départage par Battle et nouveau tarif consignés pour la reprise.
- Demeure sans cadres de chunks ; intensité liée à la part relative d’influence et actualisée quand celle-ci change.
- Protections actives rendues en couleur unie dans la même texture de pixels que Demeure, sans bouclier ni pointillés.
- Zones protégées adjacentes d’un propriétaire raccordées ; légende compacte couleur / personnage, avec masquage conservé.
- Aperçu de Pause teinté directement dans ses pixels, avec brouillard et vide conservés.
- Infobulle Demeure « Présence et influence d’habitation » en FR/EN ; retrait de « aucun claim ni protection ».
- Aucun changement des données, droits, prix ou durées ; aucune attribution depuis Demeure ni aucun transfert par duel automatique.
- [Contrat et vérifications](docs/demeure-overlay-beta274.md).
# beta.273 — Claims aux couleurs de leurs propriétaires
- Teinte et bordure utilisent la couleur persistante Hello World du propriétaire, y compris déconnecté.
- Protection active : bordure pleine et bouclier ; sans protection : bordure en pointillés.
- Bouton Propriétés : oui/non, partagé avec l’aperçu de Pause ; clic droit conservé quand les claims sont masqués.
- Apparition automatique avec les tuiles de la carte proche, de l’Atlas et de l’aperçu de Pause.
- Achats actualisant le claim visible ; expiration modifiant le style sans retirer la propriété.
- Légende et survol FR/EN avec nom et durée restante ; terrain inconnu non révélé.
- Aucun changement du registre, des prix, de la durée ou des droits.
- [Contrat et vérifications](docs/claim-map-beta273.md).
# beta.272 — Claims et protection sur la carte
- Clic droit sur un chunk : panneau de revendication ou de protection sur la carte, sans grille ni changement de page.
- Propriété ouvre directement la carte ; terrain proche visible dès Hello World, Atlas complet conservant ses prérequis.
- Cinq chunks gratuits par joueur, puis 100 points d’XP supplémentaires à chaque nouveau claim.
- 64 saphirs par chunk ajoutent sept jours de serveur en marche ; aucun temps décompté pendant l’arrêt.
- Minage ×2 pour le propriétaire et ×0,5 pour les autres pendant la couverture ; pose et interactions ordinaires.
- Nouveau registre séparé, achats avec reçu natif et migration volontaire des anciens volumes remboursant leurs extensions.
- Stabilisation de la sauvegarde des statistiques à la fermeture face au nettoyage de déconnexion.
- [Contrat, migration et vérifications](docs/chunk-claims-beta272.md).
# beta.271 — fiche Habitant, statistiques et activité
- Combats rétabli dans Communauté sous Habitant ; Story retiré de Pause.
- Navigation Propriété, Factions et Familier en haut ; personnage aéré à gauche et biographie à droite.
- Six compteurs personnels sous la biographie, lus dans Minecraft et le Blocodex.
- Activité sous le personnage jusqu’en bas, avec défilement et bouton Historique dans son pied de panneau.
- Publications personnelles, progrès et progression réunis sans nouveau format de sauvegarde.
- Aperçu du familier retiré d'Habitant ; réglages et consentement regroupés dans sa page dédiée.
- [Contrat, sources et vérifications](docs/inhabitant-menu-beta271.md).
# beta.270 — première propriété d’habitant
- Faction et Battle déplacés de Pause vers la fiche Habitant, avec un accès Propriété.
- Une réservation gratuite après Hello World, dans un volume fini indépendant des chunks.
- Quatre extensions de 100 à 400 points d’XP, sauvegardées avec un reçu natif.
- Contrôles serveur du minage, de la pose et des interactions des autres joueurs ; terrains libres jouables.
- Menu natif FR/EN, commandes `/sanctuary property`, `propriete` et `propriété`, aperçu sans écriture de terrain.
- [Contrat, sauvegarde additive et limites de protection](docs/property-beta270.md).
# beta.172 — cartes de horde utilisables en jeu
- Suppression de la restriction au cercle laboratoire : une carte peut ouvrir
+319 -3
View File
@@ -1,11 +1,325 @@
# Sanctuary
## beta.280 — JEI réintégré et packwiz serveur
[Un seul JAR Sanctuary avec le fork JEI et un profil packwiz serveur](docs/jei-server-beta280.md).
Client et serveur partagent le même artefact ; le profil serveur ne contient
que Sanctuary et Fabric API. Le [kit Linux](docs/server-pack.md) prépare les
mises à jour et la connexion aux services Web existants. Build, 13 GameTests,
sept contrôles de l'outil de mise à jour, installation packwiz et démarrage du
JAR de production vérifiés. La nouvelle CT 350 sur `gpu` accueille le serveur
de 20 places, relié à l'API, à la communauté et à la console privée du site.
[Release beta.280](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.280).
Les **quatre expansions validées sont disponibles dans Sanctuary normal** :
Nord glacial, Sud des canyons, Ouest océanique et Est volcanique.
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.278 — Disputer une propriété dans Battle
[Duel consenti pour un claim déjà revendiqué](docs/claim-battle-beta278.md).
Clic droit sur la propriété → Disputer via Battle. Le challenger propose et
le propriétaire accepte explicitement le chunk mis en jeu ; les deux confirment
ensuite Prêt dans Battle. Le vainqueur reçoit ce seul claim avec sa protection
restante. Refus, annulation avant lancement ou combat sans vainqueur ne changent
pas le propriétaire. Résultat et transfert disposent d’un journal de reprise.
Build, **40 tests serveur ciblés**, **26 contrôles du journal** et parcours
**Vulkan FR/EN GUI 2/3/4** réussis. [Pack local beta.278](build/Sanctuary-beta.278.mrpack).
## beta.277 — Habiter avant de revendiquer
[Claims reliés aux influences Demeure](docs/claim-demeure-beta277.md).
Une simple traversée ne suffit plus. Sur un terrain libre, l’habitant le plus
influent peut revendiquer ; à égalité, la première trace d’habitation départage.
Un autre habitant peut donc prendre la priorité avant le claim en dépassant
l’influence du premier. Les propriétés acquises restent attribuées, avec les
mêmes prix et protections. Le clic droit indique la condition ou le nom prioritaire.
Build, **34 tests serveur ciblés**, contrôles Demeure et parcours **Vulkan
FR/EN GUI 2/3/4** réussis. [Pack local beta.277](build/Sanctuary-beta.277.mrpack).
## beta.276 — Contours des propriétés et protection à huit saphirs
[Contours CTM aux couleurs des propriétaires](docs/claim-contours-beta276.md),
visibles aussi sans protection. Les chunks voisins d’un même joueur se raccordent ;
les chunks protégés ajoutent un fond coloré. **8 saphirs par chunk pour trois jours
de serveur en marche**, avec conservation des durées déjà achetées.
Build, **30 tests serveur ciblés**, **63 vérifications du registre** et parcours
**Vulkan FR/EN GUI 2/3/4** réussis. [Pack local beta.276](build/Sanctuary-beta.276.mrpack).
## beta.275 — Habitant et Pause autour de la carte
[Habitant en trois colonnes et Pause centrée sur l’Atlas](docs/menu-atlas-beta275.md).
Statistiques transparentes à gauche, personnage au centre, biographie à droite ;
Factions et Familier restent seuls en haut. Le temps joué utilise jours, heures,
minutes et secondes. Le message d’arrivée Propriété et l’activité de la fiche
sont retirés. Les propriétés exigent la Carte débloquée et active côté serveur.
Après déblocage, Aventure et Communauté forment deux colonnes au-dessus de la
carte ; Partie et ses options restent dessous, Gazette à gauche et Tableau à droite.
Build, **30 tests serveur ciblés** et parcours **Vulkan FR/EN GUI 2/3/4** réussis.
[Pack local beta.275](build/Sanctuary-beta.275.mrpack).
## beta.274 — Calques continus et propriétés conservées
[Demeure et les protections utilisent des couleurs par pixel sans cadres de chunks](docs/demeure-overlay-beta274.md).
Les protections voisines de même propriétaire se raccordent, avec une légende
couleur / personnage ; les boucliers et pointillés sont retirés. Le bouton
Propriétés conserve le masquage. L’aide de Demeure décrit l’habitation et son
influence. La fiche Habitant et les claims actuels restent accessibles.
[La refonte des règles, de l’habitation préalable, des duels et du tarif est mise en attente](docs/property-demeure-refonte.md).
Build, **29 tests serveur ciblés** et parcours **Vulkan FR/EN GUI 2/3/4** réussis.
[Pack local beta.274](build/Sanctuary-beta.274.mrpack).
## beta.273 — Couleurs et protection des claims
[Les claims portent la couleur de leur propriétaire sur la carte](docs/claim-map-beta273.md),
même hors ligne. Bordure pleine et bouclier pour les chunks protégés ; pointillés
pour les autres. Affichage dès l’ouverture, actualisation après achat et état
au survol, dans la carte proche, l’Atlas et son aperçu de Pause. Le bouton
**Propriétés : oui/non** permet de les masquer et de les réafficher.
Build, **26 tests serveur ciblés** et parcours **Vulkan FR/EN GUI 2/3/4** réussis.
[Pack local beta.273](build/Sanctuary-beta.273.mrpack).
## beta.272 — Claims sur la carte
[Clic droit sur le terrain pour revendiquer un chunk](docs/chunk-claims-beta272.md).
Les cinq premiers sont gratuits, puis 100, 200, 300 points d’XP, etc.
Chaque chunk peut être protégé pour **64 saphirs et sept jours de serveur en
marche** : minage ×2 pour son propriétaire, ×0,5 pour les autres. L’arrêt du
serveur suspend la durée ; le claim reste acquis après expiration.
Migration volontaire des anciens volumes, avec remboursement des extensions.
Build, **25 tests serveur ciblés**, **58 vérifications du registre** et parcours
**Vulkan FR/EN GUI 2/3/4** réussis. [Pack local beta.272](build/Sanctuary-beta.272.mrpack).
## beta.271 — Habitant, statistiques et activité
[Fiche en deux colonnes et deux rangées](docs/inhabitant-menu-beta271.md).
Propriété, Factions et Familier forment une barre en haut. Le personnage
respire en haut à gauche, avec son activité jusqu’en bas et Historique dessous.
La biographie et les statistiques occupent la droite. Le familier et ses
réglages restent dans sa page dédiée. Combats revient dans Communauté ;
Story quitte Pause. Build, **15 tests serveur ciblés** et parcours **Vulkan
FR/EN GUI 2/3/4** réussis. [Pack local beta.271](build/Sanctuary-beta.271.mrpack).
## beta.270 — Propriété personnelle et fiche Habitant
[Première propriété gratuite après Hello World](docs/property-beta270.md) :
volume initial de 17 × 17 × 17 blocs, emplacement choisi dans le monde principal,
quatre agrandissements en points d’XP et protection des actions directes des
autres joueurs. Accès par **Habitant → Propriété** ou `/sanctuary property`.
Faction et Battle rejoignent Habitant et libèrent leurs places dans Pause.
Explosions, pistons, fluides et autres dégâts environnementaux restent hors
protection dans ce premier lot. **41 GameTests serveur ciblés** et parcours
**Vulkan FR/EN GUI 2/3/4** réussis.
[Pack local beta.270](build/Sanctuary-beta.270.mrpack).
## beta.269 — Les réponses de Providence
[Six issues et leurs couleurs](docs/providence-verdicts-beta269.md) :
Exaucée en vert, Différée en jaune, Refusée en rouge, Incomprise en gris,
Alternative en violet ; Silence sans message visible. Couleurs conservées
en SGA et en lecture normale FR/EN. Un verdict différé programme réellement
le miracle plus tard ; une alternative accorde une autre aide admissible.
Build, **191 vérifications indépendantes**, **22 GameTests serveur** et parcours
**Vulkan FR/EN GUI 2/3/4** réussis. [Pack local beta.269](build/Sanctuary-beta.269.mrpack).
## beta.268 — Les quatorze prières
[Catalogue complet de Providence](docs/providence-fourteen-beta268.md) :
récupération, retour, secours, soleil, pluie, guérison, nourriture, foudre,
malédiction, vengeance, vol, désarmement, téléportation et réparation.
Ciblage par `@Pseudo`, prières hostiles autorisées contre tous les joueurs
connectés, transferts réels des objets et scellement des tombes consommées.
L’attente de cinq secondes et les couleurs SGA sont conservées.
Build, **146 vérifications indépendantes**, **20 GameTests serveur** et parcours
client **Vulkan** réussis. [Pack local beta.268](build/Sanctuary-beta.268.mrpack).
## beta.267 — Providence prend le temps de répondre
[Attente de cinq secondes après `/pray`](docs/providence-delay-beta267.md),
réglable entre 1 et 60 secondes. Une seule prière en attente par joueur,
répétitions ignorées, conditions vérifiées à l'échéance et réponses colorées
conservées. Build, 83 vérifications indépendantes, 15 GameTests serveur et
parcours Vulkan avec réponse après cinq secondes réussis.
[Pack local beta.267](build/Sanctuary-beta.267.mrpack).
## beta.266 — Couleurs de Providence
[Réponses SGA selon le verdict](docs/providence-colors-beta266.md) : vert pour
un miracle accordé, jaune pour une attente ou une prière à clarifier, rouge
pour un refus ou un échec. Build, 67 vérifications indépendantes, 15 GameTests
serveur et parcours Vulkan FR/EN GUI 2/3/4 réussis.
[Pack local beta.266](build/Sanctuary-beta.266.mrpack).
## beta.265 — Providence
[Prières et premiers miracles](docs/providence-beta265.md) : `/pray` interprète
les concepts d'un dictionnaire JSON rechargeable, puis décide côté serveur
d'éclaircir le temps ou de ramener le joueur près de sa dernière mort, si une
arrivée sûre est disponible dans les chunks chargés. Probabilités et cooldowns
persistants, journal borné, réponses SGA et lecture normale FR/EN avec
`/providence readable true`. Restitution des objets et secours préparés,
**désactivés** en attendant leurs contrats de sécurité.
Build, **67 vérifications indépendantes**, **15 GameTests serveur** et parcours
client **Vulkan FR/EN GUI 2/3/4** réussis.
[Pack local beta.265](build/Sanctuary-beta.265.mrpack).
## 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,
@@ -3353,9 +3667,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
+26 -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,10 @@ 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.280, le fork Minecraft 26.3 `30.32.0-sanctuary.4` est à nouveau
embarqué dans le JAR Sanctuary, avec la licence d’origine et
[la provenance du portage](mods/jei/PROVENANCE.md). Sa distribution séparée
facultative concernait les beta.257–278.
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.
+23 -1
View File
@@ -3,9 +3,24 @@ 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') }
tasks.register('verifyServerUpdater', Exec) {
group = 'verification'
commandLine('python3', 'scripts/test_server_runtime.py')
}
tasks.named('check') { dependsOn('verifyServerUpdater') }
tasks.register('verifyPack', Exec) {
group = 'verification'
description = 'Verify pinned pack dependencies, versions and index hashes.'
@@ -15,10 +30,17 @@ 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')
}
tasks.register('assembleServerPack', Exec) {
group = 'distribution'
description = 'Build the Linux server kit and verify its packwiz profile.'
dependsOn('assemblePack')
commandLine('python3', 'scripts/server_pack.py')
}
tasks.register('verifyResourcePack', Exec) {
group = 'verification'
description = 'Verify the versioned Sanctuary texture sources and their release hashes.'
+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.
+95
View File
@@ -1,5 +1,100 @@
# 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
+101
View File
@@ -0,0 +1,101 @@
# CLAIM-03 — Chunks et protection temporaire, beta.272
Branche `codex/inhabitant-property`. Contrat du 24 octobre 2026, avant changement des données.
Un claim est un chunk entier de 16 × 16 blocs du monde principal, sur toute
sa hauteur. L'admission Hello World donne accès au menu, sans terrain attribué.
Les cinq premiers chunks sont gratuits ; le sixième coûte 100 points d'XP,
le septième 200, puis +100 par chunk. Limite technique : 256 chunks par joueur,
10 000 par monde. Les chunks peuvent être séparés. Aucun abandon ni transfert
n'est proposé dans ce lot.
Chaque chunk peut recevoir sept jours de protection contre **64 saphirs**.
Le propriétaire mine alors à ×2 et les autres à ×0,5. Sans protection : ×1.
L'effet suit le bloc effectivement miné, même dans une sélection groupée.
La pose, l'utilisation, les explosions, fluides et autres mécanismes gardent
leurs règles normales. Le claim demeure après expiration. Le renouvellement
est manuel et ajoute sept jours à la durée restante, sans prélèvement automatique.
Le temps compte en millisecondes monotones de serveur démarré, y compris sans
propriétaire connecté. Il ne dépend ni du calendrier ni du nombre de ticks.
L'arrêt normal sauvegarde l'horloge ; la relance reprend sa valeur sans compter
l'arrêt. Sauvegarde toutes les 60 secondes et à chaque achat : un arrêt brutal
peut restituer au plus le temps depuis le dernier point de sauvegarde (environ
60 secondes, ou davantage si la boucle serveur est bloquée).
## Contrat de sauvegarde et migration
Nouveau registre séparé `<monde>/sanctuary/chunk-claims-v1.json`, schéma 1,
lié à la graine. Il contient l'horloge serveur, une révision, les chunks, les
transactions en attente et les UUID ayant volontairement quitté l'ancien modèle.
Écritures atomiques vérifiées ; format invalide conservé et mutations suspendues.
Un reçu persistant natif joueur `sanctuary:chunk_purchase_receipt_v1` relie le
débit XP/saphirs ou le remboursement à la validation du registre. Une transaction
réservée sans reçu sauvegardé est annulée ; un reçu durable la valide une seule fois.
L'ancien `properties-v1.json` reste dans son format exact. Ses volumes et leurs
droits restent actifs tant que le propriétaire ne confirme pas la migration.
La migration retire volontairement les droits de son volume et rembourse les
extensions : 100, 300, 600 ou 1 000 XP selon le palier. Aucun chunk n'est créé
à partir du volume : le joueur choisit ensuite ses chunks, dont cinq gratuits.
Les achats anciens en attente sont résolus avant cette migration. Un nouveau
chunk ne peut recouvrir l'emprise horizontale d'un volume ancien encore actif.
Aucun bloc, terrain, génération, seed ou sauvegarde personnelle n'est modifié.
Un **clic droit directement sur la carte** ouvre un petit panneau sur le chunk :
propriétaire, tarif, durée et bouton Revendiquer ou Protéger. Le prix apparaît
avant l'action ; le clic sur ce bouton réalise l'achat sans quitter la carte.
Échap ou un clic ailleurs ferme ce panneau. Aucun menu de grille séparé et aucun
mode à activer pour le clic droit de l'Atlas ordinaire.
Habitant → Propriété et `/sanctuary property` ouvrent cette carte directement.
Avant l'aptitude Atlas, elle montre uniquement le terrain déjà chargé et envoyé
au joueur, à huit chunks au plus, sans conserver un historique supplémentaire.
Les échantillons sont bornés à quatre tuiles par joueur et par tick, dans un
budget global de 2 ms, sans charger de chunks. L'Atlas complet garde ses
prérequis ; une fois débloqué, les tuiles découvertes peuvent aussi être choisies.
Le serveur contrôle la portée, la frontière, la réserve de départ de 32 blocs,
les fonds et la révision. La migration de l'ancien volume requiert une seconde
confirmation explicite dans le panneau, avec le remboursement indiqué.
## Validation
Les **58 vérifications indépendantes** du registre passent : cinq claims gratuits,
prix suivants, conflits, révision périmée, reprise avec ou sans reçu, horloge,
trente jours d’arrêt simulés, renouvellement, expiration, migration et refus de
JSON invalide ou d’une graine différente.
Le parcours client **Vulkan**, en **FR/EN GUI 2/3/4**, réussit en **54 s** :
admission réelle, carte proche avant aptitude Atlas, recentrages rapides,
clic sur un chunk voisin (9, 8 alors que le joueur est en 8, 8), premier claim,
débit d’un stack de saphirs, puis clic droit dans l’Atlas ordinaire, panneau
compact et Échap conservant la carte. Les huit captures finales sont dans
`build/claims272-screenshots/` ; les formats FR GUI 2/4 et EN GUI 4 ont été
inspectés. La fermeture ne reproduit plus l’exception de parcours des sessions
Blocodex : le flush parcourt désormais un instantané stable.
Les tests natifs ciblent les paiements XP et saphirs, les reçus sauvegardés dans
le joueur, les vrais `getDestroyProgress`, la frontière du chunk, la hauteur,
les interactions libres, la migration remboursée et les refus terrain inconnu / spawn.
Le lot couvre aussi les anciens volumes, le minage, les menus et l’overview.
`check build assemblePack` réussit : **25 tests natifs ciblés**, en 2 min 44 s
pour la commande complète. Le MRpack beta.272 est exporté et vérifié : ZIP,
Minecraft 26.3, Loader 0.19.5 et octets des deux JAR locaux identiques aux builds.
Les archives beta.270 et beta.271 gardent leurs hashes antérieurs.
La validation finale et le MRpack sont consignés dans
`build/claims272-validation.json`.
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=claims272,property270,mining,overview271,menus \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryClaims272ClientTests=true -PsanctuaryQuickTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
Le multiplicateur s’applique à la progression native ; les blocs instantanés,
le Créatif et la granularité des ticks conservent leur fonctionnement Minecraft.
Abandon, transfert, partage de droits et protection contre les mécanismes
indirects restent hors de ce lot. Aucun serveur ou instance personnelle déployé.
+120
View File
@@ -0,0 +1,120 @@
# CLAIM-278 — Duel Battle consenti pour une propriété
Branche `codex/claim-battle-duels`, demande du 25 octobre 2026.
Le créateur confirme que cette étape vise les propriétés déjà revendiquées.
## Contrat du duel
Depuis le clic droit sur un claim, un autre habitant peut proposer de le disputer.
Le challenger doit avoir atteint le seuil d’habitation Demeure sur ce chunk ;
la priorité d’influence ne lui est pas nécessaire pour une proposition consentie.
Le propriétaire garde ses droits acquis, même sans preuve Demeure rétroactive.
Les deux joueurs doivent avoir Carte active et être disponibles pour Battle.
Le challenger valide une proposition explicite ; le propriétaire accepte ou
refuse celle-ci. Le chunk, les deux participants, le temps de protection restant
et les règles sont affichés. Une acceptation ne vaut que pour cette proposition.
Une modification du claim avant acceptation invalide la proposition.
L’acceptation crée un Battle privé à deux joueurs : joueurs seuls, rayon de
48 blocs autour du lieu annoncé par le challenger, cinq minutes, nourriture,
potions et perles autorisées, mise d’entrée nulle. Les deux joueurs doivent
être dans ce périmètre. Chacun valide ensuite Prêt dans Battle ; le challenger
lance la préparation native de dix secondes. Aucun troisième participant ne
peut rejoindre ce duel. Les morts, tombes, abandons et déconnexions pendant
le combat suivent les règles Battle existantes. Elles sont rappelées avant
l’accord. Pas de téléportation ni terrain créé automatiquement.
Le vainqueur devient propriétaire du seul chunk annoncé. Le défenseur gagnant
conserve son claim. L’échéance absolue de protection est conservée : aucun temps
n’est ajouté, retiré ou remboursé. Couleur, nom et multiplicateurs de minage
suivent le nouveau propriétaire. Aucun coût XP ou saphir supplémentaire n’est
prélevé pour transférer un claim déjà acheté.
Refus, annulation avant lancement, expiration d’invitation, égalité ou interruption
du serveur sans résultat enregistré : aucune mutation de propriété. Un abandon
ou une déconnexion après lancement vaut défaite selon Battle. Les autres claims
et les duels ordinaires restent indépendants.
## Contrat de persistance et reprise
Aucune modification des schémas de Demeure, des claims, des mises Arena ou des
reçus joueurs. Aucun monde personnel, chunk ou génération n’est modifié par les
essais. Le transfert met à jour uniquement le propriétaire d’un claim existant,
dans le schéma 1 de `sanctuary/chunk-claims-v1.json`, par écriture atomique.
Nouveau journal indépendant `sanctuary/claim-duels-v1.json`, schéma 1, lié à la
graine. Il enregistre les deux accords sur le chunk et son état initial avant
la création du Battle, puis le vainqueur avant toute mutation de propriétaire.
Le résultat est appliqué par comparaison de l’état attendu, puis clôturé. Une
reprise après interruption entre l’écriture du résultat et sa clôture applique
ce transfert une seule fois. Les accords sans résultat durable sont annulés
au redémarrage ; les mises Battle suivent leur propre journal de remboursement.
Les mutations du claim sont bloquées pendant un accord actif ou un résultat à
régler. Les limites de propriété sont vérifiées avant accord ; les participants
inscrits à Battle ne peuvent acheter d’autres claims pendant celui-ci. Les
fichiers invalides ou modifiés extérieurement sont conservés et les nouveaux
duels sont refusés. Une erreur avant écriture durable du vainqueur ne transfère
pas la propriété ; une erreur après cette écriture garde le résultat à reprendre.
Terminer les duels et solder le journal avant retour à une version antérieure.
## Vérification
`check build assemblePack` réussit en **2 min 55 s**, avec **40 tests serveur
ciblés**. Les six nouveaux scénarios vérifient l’absence de transfert ou de
combat sans le deuxième accord, les refus du challenger et des tiers tentant
d’accepter à la place du propriétaire, le refus du propriétaire, la relecture
de la proposition la plus récente, les validations Prêt natives, la mort réelle
résolvant Battle et transférant le chunk exact, la conservation de son échéance,
l’abandon après lancement, l’annulation avant lancement, l’égalité native au
délai écoulé et l’invalidation d’un enjeu modifié ou expiré. Une arène publique
ordinaire accepte toujours un troisième inscrit ; y gagner ne donne aucun claim.
Les **26 vérifications pures** du journal couvrent le consentement durable,
les identités et chunks réservés, le refus d’un gagnant tiers, le redémarrage
sans résultat, le résultat enregistré avant transfert, la reprise après mutation
avant clôture, l’idempotence, la victoire du défenseur, les conflits de propriétaire
ou de protection, la limite de 256 claims, la graine, les nombres JSON stricts
et les fichiers corrompus ou modifiés extérieurement. Les **63 contrôles du
registre de claims**, Demeure et les scénarios historiques ciblés passent aussi.
Le parcours **Vulkan FR/EN GUI 2/3/4** réussit en **53 s**, avec **18 captures**.
Il ouvre le dossier depuis le clic droit de l’Atlas, lit les conséquences et
les boutons d’acceptation/refus dans les deux langues aux trois tailles, refuse
par le vrai paquet client, puis accepte une nouvelle proposition. L’enjeu est
présent dans Battle avant Prêt. Une victoire native du propriétaire conserve
son terrain. Après le délai de sortie de combat existant, le client devient
challenger d’une autre propriété ; son propriétaire accepte indépendamment,
les deux valident Prêt et le client lance réellement Battle. La mort native du
défenseur transfère le claim et son échéance exacte. La carte déjà ouverte
reçoit le nouveau propriétaire/couleur et raccorde le CTM au claim voisin sans
réouverture. Captures : `build/claims278-screenshots/` ; inspections des écrans
FR GUI 4 d’accord, FR GUI 2 de Battle et de la carte après transfert.
La réouverture d’un dossier n’est pas limitée par le délai anti-double-action :
les lectures peuvent changer immédiatement de proposition, tandis que les
mutations restent temporisées. Le panneau d’accord dispose d’un fond lisible
et son compteur n’oblige pas à revenir en haut du défilement chaque seconde.
Les combattants de ce parcours sont à l’air libre dans un monde plat ignoré.
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=claims278,claims277,menu275,claims273,claims272,property270,mining,overview271,menus,map,demeure \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryClaims278ClientTests=true -PsanctuaryQuickTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
MRpack beta.278 exporté et vérifié : intégrité ZIP, Minecraft 26.3, Loader 0.19.5,
versions/libellés, nouveaux contrôleurs présents et deux JAR identiques aux
builds locaux. Les huit archives beta.270 à beta.277 gardent leurs empreintes.
Reçu : `build/claims278-validation.json`. Aucun déploiement de serveur, monde
personnel ou instance Prism, ni publication de canal.
Le lot porte sur un seul chunk déjà revendiqué et un Battle entre joueurs.
Les familiers/modes mixtes, ensembles de claims, transferts sans combat ou
attributions de terrains encore libres ne sont pas ajoutés. Les reprises sont
éprouvées aux étapes du journal, sans simulation d’une panne physique du disque.
+80
View File
@@ -0,0 +1,80 @@
# CLAIM-276 — Contours CTM et protection à huit saphirs
Branche `codex/inhabitant-property`, demande du 25 octobre 2026.
## Résultat demandé
Toutes les propriétés doivent apparaître sur l’Atlas et son aperçu de Pause.
Les claims d’un même propriétaire forment un contour continu, sans traits
internes entre chunks adjacents. Une propriété sans protection garde uniquement
son contour ; un chunk protégé ajoute un fond à la couleur du propriétaire.
La protection coûte désormais **8 saphirs par chunk pour trois jours de serveur
en marche**. Un arrêt du serveur suspend toujours le compteur ; une déconnexion
ne le suspend pas. Les multiplicateurs de minage ×2 / ×0,5 restent identiques.
## Rendu et accès
- Contour d’un pixel de carte, directement dans les textures de terrain et de
calque, avec les mêmes coordonnées et le même échantillonnage que la carte.
- Raccords par propriétaire sur les quatre côtés et les angles rentrants ;
la protection d’un voisin ne crée pas de frontière intérieure de propriété.
- Fond uniquement sur les chunks encore protégés. À expiration, le contour
reste visible et la propriété demeure attribuée.
- Toute arrivée, suppression ou modification de claim recalcule aussi ses
voisins, y compris les diagonales. Les achats actualisent la carte ouverte.
- Couleur persistante du personnage, même hors ligne ; légende compacte pour
tous les propriétaires visibles, survol et masquage conservés.
- Aucun pixel de brouillard ou de vide n’est coloré. Seules les métadonnées des
tuiles effectivement transmises sont acceptées. L’aptitude Carte achetée et
active reste obligatoire pour ouvrir et gérer les propriétés.
- Demeure et la disposition Habitant / Pause de beta.275 sont conservés.
## Contrat de conservation
Le schéma 1 de `sanctuary/chunk-claims-v1.json` et les reçus natifs restent
identiques. Aucune réécriture automatique d’échéance, conversion de terrain,
remise à zéro, attribution depuis Demeure ou régénération n’est effectuée.
Les protections de sept jours déjà payées gardent leur échéance exacte.
Une transaction de sept jours préparée par beta.272–275 reste récupérable :
son reçu de paiement natif valide sa durée d’origine ; sans reçu, elle est
annulée comme avant, sans retirer la couverture antérieure. Le lecteur accepte
les deux périodes préparées, trois et sept jours ; les nouvelles réservations
créées par beta.276 utilisent uniquement trois jours. Chaque renouvellement
ajoute trois jours à l’échéance en cours si elle est encore active, sinon au
temps serveur courant, contre huit saphirs. Aucun remboursement rétroactif
n’est appliqué aux achats antérieurs.
## Vérification
`check build assemblePack` réussit en **2 min 38 s**, avec **30 tests serveur
ciblés** et **63 vérifications du registre**. Les achats natifs refusent sept
saphirs sans mutation, consomment exactement huit saphirs répartis sur plusieurs
piles, couvrent un seul chunk pour trois jours et ajoutent exactement trois
jours lors d’un renouvellement. La reprise avec/sans reçu d’une ancienne période
de sept jours et le renouvellement de cette couverture après redémarrage sont
vérifiés. Les protections et multiplicateurs restent applicables sans Carte.
Le parcours **Vulkan FR/EN GUI 2/3/4** réussit en **50 s**, avec **19 captures**.
Il achète réellement Carte et une protection, contrôle les pixels du contour et
du fond, le raccord en L et carré, les frontières entre propriétaires, le nom et
la couleur du propriétaire hors ligne, l’arrivée d’un voisin sans réouverture,
l’expiration dans le modèle, les paquets périmés et la borne du monde, le
masquage/réaffichage et les pixels de l’aperçu de Pause. Les libellés montrent
huit saphirs et trois jours. Captures dans `build/claims276-screenshots/` ;
inspection des vues carte FR GUI 2, contexte FR GUI 4 / EN GUI 2 et Pause GUI 4.
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=menu275,claims273,claims272,property270,mining,overview271,menus,map,demeure \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryClaims276ClientTests=true -PsanctuaryQuickTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
MRpack beta.276 exporté et vérifié : intégrité ZIP, Minecraft 26.3, Loader 0.19.5,
versions et libellés FR/EN, deux JAR embarqués identiques aux builds locaux.
Les six archives beta.270 à beta.275 gardent leurs empreintes. Reçu :
`build/claims276-validation.json`. Aucun déploiement personnel ni publication
de canal n’est effectué.
+98
View File
@@ -0,0 +1,98 @@
# CLAIM-277 — Habitation et influence avant revendication
Branche `codex/claims-demeure`, demande du 25 octobre 2026.
Le créateur confirme le seuil existant de Demeure et précise qu’un autre joueur
peut revendiquer le terrain s’il prend plus d’influence avant le premier claim.
## Règles
Une simple visite ne permet plus de revendiquer un chunk. Demeure doit y
reconnaître un habitant : influence totale d’au moins 0,8, dont au moins 20 %
pour un joueur, selon les seuils déjà utilisés par sa lecture du territoire.
Aucun compteur de minutes, nuits ou délai supplémentaire n’est introduit.
Sur un chunk libre, l’habitant avec le plus d’influence effective actuelle peut
revendiquer. À égalité (tolérance numérique 10⁻⁹ pour les arrondis), l’ordre
persisté des premières contributions acceptées départage les habitants.
Ainsi, le premier garde la priorité jusqu’à ce qu’un autre le dépasse. Les
contributions peuvent précéder l’achat de Carte. Une identité hors ligne peut
rester prioritaire ; les autres joueurs peuvent la dépasser par leur activité.
La décroissance native de Demeure continue de s’appliquer aux terrains libres.
Le clic droit sur la carte explique la condition manquante ou nomme l’habitant
prioritaire ; l’action est désactivée si la condition n’est pas remplie. Le
panneau d’un terrain libre s’actualise. Commande et paquets d’action vérifient
la même priorité côté serveur, au moment de l’achat, avant toute réservation
ou dépense. Aucun duel ou transfert automatique n’est ajouté.
Une fois revendiqué, le claim reste attribué malgré les variations d’influence.
Tous les claims antérieurs et leurs durées de protection sont conservés, sans
exiger de preuve rétroactive. Leur protection reste renouvelable même si Demeure
est indisponible. Cinq claims gratuits puis XP, huit saphirs pour trois jours de
serveur, contours CTM et accès par Carte achetée et active restent inchangés.
## Contrat de conservation
Aucun nouveau fichier ni changement de schéma. `data/demeure/footprints-v1.json`
et `sanctuary/chunk-claims-v1.json` restent identiques. L’ordre des premières
traces est déjà conservé par la liste des influences et son rechargement ; on
ne prétend pas reconstituer une date de franchissement du seuil absente des
anciennes données. La requête de priorité est une lecture des empreintes,
indépendante de leur tri visuel par influence et de l’UUID des joueurs.
Demeure conserve la responsabilité des traces, des budgets, des seuils, de la
décroissance et de leur persistance. Sanctuary applique ses conditions de
revendication. Les empreintes sont sauvegardées atomiquement avant de réserver
ou débiter un nouveau claim. Une lecture ou écriture refusée empêche cet achat
sans effacer le fichier ; les propriétés déjà acquises restent valables.
Un paquet de lecture `sanctuary:claim_residency_v1` fournit l’état et le nom/couleur
prioritaire pour le seul chunk sélectionné et accessible. Les anciens codecs
et identifiants restent inchangés. Aucun état d’habitation n’est transmis pour
un terrain inaccessible ou avant Carte. Aucun monde personnel ni génération
n’est modifié par les essais.
## Vérification
`check build assemblePack` réussit en **2 min 52 s**, avec **34 tests serveur
ciblés**. Les quatre nouveaux tests couvrent les activités natives nécessaires,
le refus d’un passage ou d’un achat sans habitation, cinq claims gratuits puis
le coût du sixième, l’égalité et le dépassement avant revendication, l’acquisition
par le nouvel habitant prioritaire, l’absence de transfert après achat et le
renouvellement sans domination Demeure. Les propriétés anciennes sans preuve
rétroactive restent protégées ; le paquet de lecture est testé, ainsi que le
refus de divulguer les habitants d’un chunk inaccessible ou avant Carte.
Les tests purs Demeure ajoutent **14 scénarios d’habitation, priorité et point
de sauvegarde**, dont l’ordre conservé après rechargement, indépendamment du tri
visuel et des UUID, la décroissance, les dimensions et l’intégrité du fichier
même quand le modèle en mémoire n’a pas changé. Les **63 vérifications du registre
de claims** et les scénarios historiques ciblés passent aussi. Les montages de
paiement et de migration préparent leurs traces d’habitation indépendamment des
tentatives de claim : un clic refusé ne crée aucune influence.
Le parcours **Vulkan FR/EN GUI 2/3/4** final réussit en **51 s**, avec **28 captures**.
Il passe par Hello World et l’achat de Carte, constate le bouton désactivé avant
habitation, le refus d’un simple passage, le changement de priorité quand Nora
prend plus d’influence et son retour au joueur lorsque celui-ci la dépasse.
Un autre spot libre est réellement revendiqué par Nora après dépassement.
Le nom/couleur prioritaire et le bouton désactivé sont vérifiés dans les deux
langues aux trois tailles ; les contextes d’achat et de protection, les contours
CTM, le masquage et l’aperçu de Pause restent valides. Captures dans
`build/claims277-screenshots/`, avec inspection des panneaux FR GUI 4 et EN.
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=claims277,menu275,claims273,claims272,property270,mining,overview271,menus,map,demeure \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryClaims277ClientTests=true -PsanctuaryQuickTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
MRpack beta.277 exporté et vérifié : ZIP, Minecraft 26.3, Loader 0.19.5,
versions et libellés FR/EN, octets des deux JAR embarqués identiques aux builds
locaux. Les archives beta.270 à beta.276 restent identiques. Reçu :
`build/claims277-validation.json`. Aucune publication de canal ni synchronisation
d’installation personnelle n’est effectuée.
+71
View File
@@ -0,0 +1,71 @@
# CLAIM-04 — Couleurs et état des claims sur la carte, beta.273
Branche `codex/inhabitant-property`, demande du 24 octobre 2026.
Chaque chunk revendiqué porte la couleur persistante de son propriétaire,
choisie dans Hello World. Le nom et la couleur restent disponibles hors ligne.
Un claim protégé a une teinte plus présente, une bordure pleine et un bouclier ;
un claim sans protection a une teinte légère et une bordure en pointillés.
Le survol donne le propriétaire, l’état et la durée restante ; une légende
rappelle les deux styles. Le clic droit continue à ouvrir le panneau existant.
Un bouton **Propriétés : oui/non** masque ou réaffiche la teinte, les bordures,
les boucliers, la légende et les infobulles de claims. Le choix est partagé
avec l’aperçu de Pause et conservé tant que le client reste ouvert, y compris
après fermeture de la carte. Le clic droit et les protections restent actifs ;
les données continuent de se mettre à jour pour un réaffichage immédiat.
Ce choix de session n’ajoute aucun fichier ni format de sauvegarde.
Les apparences sont envoyées automatiquement avec les tuiles de la carte,
pour la vue proche comme pour l’Atlas. Aucun clic préalable n’est nécessaire.
Seules les tuiles déjà transmises au joueur reçoivent leurs claims ; aucun
terrain inconnu n’est révélé. Une acquisition ou un renouvellement actualise
la tuile affichée. L’expiration change le style depuis la durée serveur restante,
sans retirer la propriété. Les réponses d’une ancienne requête sont ignorées. Les achats diffusent aussi
une mise à jour ponctuelle des cellules déjà observées, indépendante d’un
rafraîchissement en cours ; aucune cellule inconnue n’est acceptée côté client.
Ce lot ajoute un paquet de présentation borné. Les identifiants existants,
registres, reçus, règles, prix, durées et formats de sauvegarde restent identiques
à beta.272. Aucune migration ni modification de terrain.
## Validation
Le parcours **Vulkan FR/EN, GUI 2/3/4**, passe en **62 s** sur un nouveau monde
plat de graine 273. Il conserve l’admission et les achats réels de beta.272,
puis compare quatre chunks contigus appartenant à deux propriétaires : chacun
a un chunk protégé et un chunk sans protection. L’un des propriétaires n’est pas
connecté. Les couleurs distinctes, le nom hors ligne, les deux styles, les
mises à jour après achat, les requêtes rapides, le clic droit, Échap et l’aperçu
de Pause sont vérifiés. Le masquage, sa conservation après fermeture, le clic
droit sous masquage et le réaffichage sont contrôlés, avec captures de la carte
et de l’aperçu de Pause sans propriétés. **19 captures** sont conservées dans
`build/claims273-screenshots/`, avec inspection FR GUI 2/4, EN GUI 4 et Pause.
Le cache est également vérifié avec une horloge contrôlée : expiration sans
perte de propriété, réponse périmée ignorée, mise à jour d’achat pendant un
rafraîchissement, cellule inconnue refusée et remplacement par une tuile vide.
Le test serveur ajouté couvre le nom et le RGB persistants d’un propriétaire
hors ligne, la durée et le codec du paquet, avec refus hors des cellules
transmises et au-delà de 64 cellules. Les registres conservent leurs 58
vérifications indépendantes de beta.272.
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=claims273,claims272,property270,mining,overview271,menus \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryClaims273ClientTests=true -PsanctuaryQuickTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
`check build assemblePack` réussit en **2 min 41 s**, avec **26 tests serveur
ciblés** et les 58 vérifications indépendantes du registre. Le MRpack beta.273
est exporté et vérifié : ZIP, Minecraft 26.3, Loader 0.19.5 et octets des deux
JAR locaux identiques aux builds. Les archives beta.270 à beta.272 restent
inchangées.
Le reçu final de build et de distribution est dans
`build/claims273-validation.json`. Aucun serveur, canal packwiz, monde ou
instance personnelle déployé. Aucun changement du comportement de protection.
+75
View File
@@ -0,0 +1,75 @@
# MAP-CTM — Calque Demeure continu, beta.274
Branche `codex/inhabitant-property`. Demande du 24 octobre 2026, après beta.273.
La [refonte des propriétés](property-demeure-refonte.md) reste un cadrage en
attente ; la fiche Habitant est conservée. Le créateur a confirmé que les claims
actuels doivent rester accessibles.
Le calque Demeure garde sa texture de 1 024 × 1 024 pixels alignée sur celle du
terrain, mais retire les cadres de chunks. Deux cellules adjacentes ayant la
même couleur et la même influence se raccordent sans couture interne, y compris
aux coins. Il ne s’agit pas d’ajouter des textures CTM de blocs dans le monde.
La teinte est uniforme dans chaque cellule connue. Sa présence suit la **part
relative d’influence** déjà calculée par Demeure, avec un alpha de 16 + 96 × part / 100
(0 si la part est nulle). Ce pourcentage n’est pas un compteur de jours habités
ou une preuve d’ancienneté. Le terrain, le brouillard et le vide gardent leurs
pixels ; seuls les chunks modifiés sont repeints. Un changement de pourcentage
actualise maintenant la teinte même si la couleur du personnage reste identique.
## Protections, dans le même calque pixel
Une protection active prend une couleur uniforme (alpha 64/255) dans la même
texture que Demeure : plus de rectangles GUI par chunk, de cadres, de pointillés
ou de boucliers. Les cellules voisines de même propriétaire se rejoignent sans
trait intérieur. Un claim sans protection reste déclaré et accessible au clic
droit, sans aplat de protection. Si les deux affichages sont activés, la couleur
de protection est prioritaire sur l’influence Demeure.
La légende associe les couleurs aux personnages ayant une protection visible,
avec trois noms au maximum puis un compteur. Le bouton de masquage reste partagé
avec Pause. Le calque est invalidé par les achats, les données de Demeure, les
changements d’affichage et l’expiration ; les cellules expirées sont repeintes
une fois. L’aperçu de Pause compose la même teinte dans ses propres pixels de
terrain, en conservant le masque du vide et du brouillard. Aucun dessin de
bordure ne dépend de l’adjacence : le raccord résulte des pixels uniformes.
L’infobulle du bouton décrit la présence et l’influence d’habitation, sans
« aucun claim ni protection ». Les règles, scores et formats de Demeure,
les droits et données de propriété, la génération et les mondes personnels
ne sont pas modifiés par cette correction d’affichage.
## Vérification
`check build assemblePack` réussit en **2 min 50 s** : **29 tests serveur ciblés**
(claims, ancien modèle, minage, overview, menus, Atlas et Demeure). Les contrôles
indépendants du registre restent réussis. Les deux parcours **Vulkan FR/EN,
GUI 2/3/4** passent : **58 s / 19 captures** pour les propriétés et **29 s /
8 captures** pour Demeure.
Les tests de pixels vérifient les bords, coins et corps identiques sur des
cellules adjacentes, la teinte seule des protections actives, les propriétaires
hors ligne, le changement de pourcentage sans changement de couleur, le vide,
le brouillard et le retrait d’une empreinte. Le masquage, le réaffichage,
l’expiration, le clic droit, la composition dans Pause et l’accès Habitant sont
couverts. Captures dans `build/map274-screenshots/` et
`build/demeure274-screenshots/` ; inspections FR GUI 4, EN GUI 3, Pause et Habitant.
Le MRpack beta.274 est exporté et vérifié : ZIP, Minecraft 26.3, Loader 0.19.5
et octets des deux JAR locaux identiques aux builds. Les archives beta.270 à
beta.273 gardent leur empreinte. Reçu : `build/map274-validation.json`.
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=claims273,claims272,property270,mining,overview271,menus,map,demeure \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryMap274ClientTests=true -PsanctuaryQuickTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
# Pour le second parcours, remplacer sanctuaryMap274ClientTests par sanctuaryDemeure274ClientTests.
```
Export local uniquement. La proposition de claim issue de l’habitation, le
premier occupant, les duels, l’accord de partage et un tarif moins élevé restent
à définir dans le cadrage de reprise ; aucun n’est annoncé comme implémenté.
+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.
+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.
+116
View File
@@ -0,0 +1,116 @@
# MENU-271 — Habitant en deux colonnes et deux rangées
Demande du 24 octobre 2026, continuation de `codex/inhabitant-property`.
Version locale `beta.271`, après le premier lot de propriété beta.270.
## Résultat demandé
- Pause retrouve **Combats / Battles** dans **Communauté**, sous Habitant.
Story et Factions ne figurent pas dans ce menu.
- Habitant présente une barre horizontale **Propriété — Factions — Familier**
en haut, sur les mêmes boutons natifs que les catégories de Découvertes.
- Le personnage et son nom occupent la cellule supérieure gauche, avec
des marges autour de la silhouette. La biographie est en haut à droite.
- Sous la biographie, un tableau de bord présente le temps joué, les blocs
posés, les blocs cassés, les créatures tuées, les morts et les progrès obtenus.
Son bouton Statistiques ouvre la page générale complète existante.
- Sous le personnage, l’activité occupe tout le panneau inférieur gauche,
jusqu’en bas du contenu. La liste affiche les dernières activités disponibles
dans son propre défilement, et **Historique reste dans son pied de panneau**.
Il ouvre les 24 dernières activités disponibles ; l’ancien journal des cycles
et achats reste accessible dans cette page.
- Aucun aperçu, nom ou réglage du familier ne figure dans Habitant. Son bouton
ouvre la page dédiée avec l'aide alliée, le carnet, les choix en attente et
les contrôles opérateur admissibles, en plus des commandes déjà présentes.
- Propriété, Factions, Familier, Statistiques et Historique reviennent à
Habitant ; Combats revient à Pause. Le pied de page natif est conservé.
Les textes FR/EN, nombres compacts et infobulles gardent les valeurs et titres
complets accessibles dans les petits formats. Biographie, statistiques et
activité défilent indépendamment, et conservent leur position à la mise à jour
des données. Les hauteurs s’adaptent aux échelles GUI 2, 3 et 4 ; la silhouette
garde de l’air autour d’elle dans la cellule supérieure gauche.
## Sources et contrat de données
L'aperçu est une **lecture personnelle côté serveur**, via l'acteur authentifié
du protocole communautaire existant. Aucun UUID du joueur à consulter n'est
accepté depuis le client. Les opérations de fichier ou JDBC restent dans le
worker communautaire ; les données Minecraft sont copiées sur le thread serveur.
- Temps, minage, morts et créatures tuées : compteurs Minecraft existants.
Le temps est celui des ticks de jeu Minecraft. Le minage suit les règles de
comptage vanilla ; les destructions en Créatif ne sont pas inventées.
- Pose : sommes des poses confirmées du Blocodex, y compris les identifiants
sauvegardés d'un mod absent. Les usages d'objets ne sont pas assimilés à des
poses. Une source endommagée est signalée indisponible, jamais remise à zéro.
- Progrès : accomplissements natifs ayant un affichage public ; les recettes
et critères incomplets ne sont pas présentés comme des achievements.
La date est le moment où toutes les exigences deviennent satisfaites :
un critère optionnel obtenu plus tard ne déplace pas cette date.
- Activité : publications, annonces, réponses et bulletins personnels visibles,
progrès achevés et achats/cycles/factions déjà datés dans les sauvegardes.
Le contenu intégral des publications et leurs photos ne sont pas transmis.
Les publications modérées et les réponses à un parent masqué sont exclues.
- Tri décroissant par date avec identifiants stables pour les égalités,
dédoublonnage et limite de 24. Les anciennes données sont lisibles sans
journal supplémentaire. Une panne communautaire conserve l'aperçu natif
et signale que les publications manquent temporairement.
Ce panneau n'enregistre pas le texte du chat, des messages privés ou toutes
les actions du monde. Il réunit les événements déjà persistants et datés ci-dessus.
Aucun format de sauvegarde, coût, droit, règle de jeu ou monde personnel ne
change. Aucun familier n'est retiré de l'inventaire ni rappelé en ouvrant Habitant.
Les artefacts beta.270 déjà exportés sont conservés.
## Vérifications
`check build assemblePack` réussit en **2 min 37 s**, avec **15 GameTests
serveur ciblés**. Le parcours Vulkan FR/EN aux échelles GUI 2, 3 et 4 réussit
en **45 s** et utilise un nouveau monde
plat de graine 271. Il vérifie la barre supérieure, les deux colonnes et deux
rangées, les marges du personnage, le panneau d’activité jusqu’en bas et son
bouton Historique, la biographie et les statistiques à droite, les défilements
et la conservation de leur position après rafraîchissement.
Des compteurs serveur connus (une heure de ticks ajoutée, deux poses et
25 blocs minés), un progrès natif et une annonce réellement publiée dans le
monde de développement sont relus par le réseau. Le parcours conserve aussi
les retours Propriété/Factions/Familier/Historique, la modification du consentement
à l’aide alliée et l’achat d’une extension de propriété.
Les tests serveur ciblés couvrent les données personnelles, les dates des
critères OR, les statistiques sans double comptage, les publications masquées,
les sources persistantes lues sans modification, les pannes communautaires et
le dédoublonnage. Le test de fichier communautaire couvre l’isolation des auteurs,
les réponses sous un parent masqué, l’ordre et la limite de 24.
La requête MariaDB reste paramétrée et bornée ; aucun serveur SQL réel n’a été
exercé pour cette livraison.
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=overview271,property270,progression,menus \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryInhabitant271ClientTests=true -PsanctuaryQuickTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
Une exécution exploratoire de la suite supplémentaire `companions` a échoué
sur `creatureProfilesActuallyMove` : « Native navigation produces an actual
airborne displacement ». Les règles de déplacement ne sont pas modifiées ici ;
ce comportement n’est pas validé ni corrigé par ce lot d’interface. Le contrôle
Vulkan du bouton Familier et de son consentement est distinct de cet essai.
Résumé conservé dans `build/inhabitant271-exploratory-navigation.txt`.
Journaux locaux ignorés : `build/inhabitant271-client-vulkan.log` et
`build/inhabitant271-check-build-pack.log`. Les **32 captures** sont conservées
dans `build/inhabitant271-screenshots/`. Les fiches Habitant FR/EN, les formats
GUI 2/4 et la page d’activité ont été inspectés.
Export local vérifié : [Sanctuary-beta.271.mrpack](../build/Sanctuary-beta.271.mrpack),
16 483 048 octets. Intégrité ZIP, versions Minecraft/Fabric, dépendances et JAR
Sanctuary/JEI comparés aux builds. Le MRpack beta.270 reste identique par SHA-256.
Reçu de validation : `build/inhabitant271-validation.json`.
Aucune publication de canal ni synchronisation d’instance personnelle.
+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.
+189
View File
@@ -0,0 +1,189 @@
# DIST-280 — JEI embarqué et packwiz serveur
Branche `codex/jei-packwiz-server`, base commit `697e4a2` (beta.278).
Version réservée `beta.280` : beta.279 appartient au chantier parallèle
Map Island Zoom, dont les modifications ne sont pas reprises ici.
## Contrat
- Un seul JAR Sanctuary à distribuer, avec le fork JEI exact
`30.32.0-sanctuary.4` et ses notices/bibliothèques embarqués. Fabric API reste
une dépendance commune ; Sodium et Sound Physics restent facultatifs côté client.
- Même JAR sur client et serveur. JEI possède une initialisation commune
(recettes et transfert), ses écrans restent dans son entrée client.
- Un manifeste serveur `server/pack.toml` sur le même canal packwiz que le
client, avec uniquement Sanctuary et Fabric API. Même version, URL et hash
du JAR Sanctuary dans les deux profils. Publication atomique des manifestes
après vérification de l'artefact immuable.
- Un kit serveur local avec mise à jour packwiz sans interface et lancement
Fabric. Les mondes, configurations, permissions, listes de joueurs et secrets
restent hors de l'index. Une erreur de mise à jour empêche le démarrage.
- Documenter les paramètres existants de l'API Sanctuary Web et de la MariaDB
communautaire, sans inventer d'API ni migrer les données ou configurer un site
public implicitement. Les secrets restent dans l'environnement du service.
## Vérifications locales
`./gradlew check build assemblePack assembleServerPack
-PsanctuaryFocusedTests=recipes,progression -PsanctuaryAtlasOnly=true` réussit :
13 GameTests requis, contrôles purs du dépôt et assemblages. Le fixture ciblé
évite le harnais worldgen historique `unified`, sans modifier sa génération.
Les sept tests de l'outil de mise à jour passent séparément, y compris le
relais du signal d'arrêt et l'attente de Java avant libération du verrou.
Installation réelle avec packwiz, deux synchronisations et six fichiers
personnels témoins conservés octet par octet. Le JAR de production démarre et
s'arrête dans un monde plat de développement neuf avec le JEI imbriqué ;
celui-ci correspond au fork construit. Minecraft normalise lui-même les fichiers
`server.properties`, `ops.json` et `whitelist.json` au lancement : leurs valeurs
sont vérifiées après cet arrêt, distinctement de la préservation par packwiz.
La transition du profil client retire réellement le JEI séparé suivi par
packwiz et conserve les favoris. Les manifestes de publication client/serveur
référencent le même artefact. Aucun nouveau parcours graphique n'est revendiqué ;
les contrôles historiques Vulkan de l'interface ne sont pas rejoués ici.
Journaux et reçus ignorés : `build/jei-server-final.log`,
`build/server280-updater-final.log`, `build/server280-validation-delivery/validation.json`,
`build/client280-validation/validation.json`.
## Serveur dédié demandé le 25 octobre 2026
Le créateur demande une **nouvelle CT pour 20 joueurs** sur le nœud `gpu`.
La CT 350 `sanctuary-beta-20` est créée à cette fin : Debian 13 amd64,
6 vCPU, 8 Gio de RAM, disque 80 Gio, non privilégiée. Le budget Java retenu est
6 Gio, avec 20 places, portée 6 et simulation 4 ; ce dimensionnement n'est pas
une mesure de charge avec vingt joueurs connectés.
L'Alpha (CT 300), les CT 330 et 340 et les mondes existants restent hors cible.
Le nouveau monde utilisera `sanctuary:sanctuary` (réglages initiaux
`sanctuary:island231_medium`), graine `28020261025`, version beta.280.
Aucune expansion n'est activée par le déploiement. Le site est dans la CT 320 ;
son API et son schéma communautaire 3 sont présents.
## Livraison et mise en service
La [release beta.280](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.280)
est publiée depuis le commit `23b295356b499404e0056affbb5d3b3811c1b01f`.
Le canal packwiz est avancé au commit `338e8a62387048c7e703ef036fda759ef57b4130`,
avec les profils client et serveur dans le même commit. Aucun JEI séparé n'est
joint. Le MRpack contient le seul JAR Sanctuary en override, et trois dépendances
téléchargées (Fabric API, Sodium et Sound Physics).
| Artefact | SHA-256 |
| --- | --- |
| `sanctuary-beta.280.jar` | `489dade38218d34d2a4502fa4ffe71f0bc13035948dc1c52b633b528e9855c7e` |
| `Sanctuary-Server-beta.280.zip` | `54ed73e75b53305a9f0cc5505f3d64624d23598f906ac69d121030255ced0d5a` |
| `Sanctuary-beta.280.mrpack` | `b23ba2d9cf72d27da2595e3f4a32a36656f71abf2d2cb4d6cefd7f9d29e4cd01` |
Le kit téléchargé dans la nouvelle CT correspond à cet artefact immuable.
Java 25, Python 3.13 et le service `sanctuary-beta.service` sont installés ;
le service est activé au démarrage. Le premier monde Sanctuary a été créé à
la graine annoncée, avec le générateur 231, taille Medium, en Survie et
`online-mode=true`. La réponse réseau Minecraft annonce 26.3, protocole 777,
**20 places**, et le JEI embarqué est chargé côté serveur.
La synchronisation packwiz a été exécutée après arrêt propre du nouveau serveur :
**61 fichiers de monde, de configuration et de secrets gardent leurs hashes**.
Le même monde redémarre ensuite. L'outil maintient son verrou jusqu'à la fin
de la sauvegarde Java, y compris lorsque systemd transmet SIGTERM.
L'API du site accepte l'authentification et refuse un corps de vérification
vide avec HTTP 422 : aucun code d'habitant n'est consommé. L'accueil serveur
confirme que les codes sont requis. Un compte MariaDB propre à Beta a été créé,
restreint à son hôte et aux sept tables nécessaires, avec TLS obligatoire ;
Connector/J utilise `sslMode=verify-full`. Le schéma existant 3 n'est pas migré.
La ligne de statistiques du nouveau serveur est alimentée toutes les 30 secondes.
La console du site était non configurée. Seules ses trois variables RCON ont
été renseignées pour Beta, après sauvegarde privée ; le cache Laravel est
reconstruit sans redémarrer le site. La commande de lecture `list`, appelée
par le vrai service PHP, répond « 0 of a max of 20 ». Le port RCON est filtré
dans la CT Beta : accès du site et de loopback uniquement, refus depuis un autre
client LAN. Aucune redirection publique RCON n'est créée.
Les secrets restent dans `/etc/sanctuary/beta.env`, le `server.properties`
privé et l'environnement Web, jamais dans Git ou packwiz. Les unités
`sanctuary-beta-rcon-firewall.service` et le complément systemd associé
appartiennent à cette installation, hors de l'index de mods.
Le domaine demandé est `beta.sanctuary-minecraft.net`. Les deux enregistrements
OVH sont publiés : A pour Beta et SRV `_minecraft._tcp.beta` vers le port 25567,
TTL 300. Les quatorze enregistrements DNS préexistants sont conservés. Une
réservation DHCP lie l'adresse de la CT à sa MAC ; elle est activée après test
de syntaxe et redémarrage de dnsmasq, sans changer ses deux fichiers existants.
Aucun serveur Minecraft ou proxy existant n'est redémarré par cette opération.
Une redirection UPnP Beta seule a servi d'amorçage. Le 25 octobre, le créateur
réenregistre la règle Bbox permanente « Sanctuary beta 20 » : **port externe
25567 → 192.168.1.122:25565**. L'API de la Bbox déclare encore `protocol=all`
(TCP/UDP) ; l'accès Minecraft TCP est vérifié.
La redirection UPnP appartenant à Beta est ensuite supprimée. Son timer est
désactivé, le service est arrêté, puis les trois fichiers du maintien sont
retirés de la configuration active et archivés dans la CT sous
`/root/sanctuary-beta-maintenance/upnp-retired-20261025-1792900244/`.
Les sept autres règles NAT permanentes et les quatre autres redirections UPnP
sont conservées. Le processus Minecraft reste le même : aucun redémarrage ni
modification d'une sauvegarde n'est nécessaire à ce retrait.
Après suppression du secours, `beta.sanctuary-minecraft.net:25567` répond bien
Minecraft **26.3**, protocole **777**, **0/20**, avec le MOTD du nouveau Beta.
Le SRV public permet de saisir seulement `beta.sanctuary-minecraft.net`.
La vérification utilise le chemin public depuis le LAN (hairpin) ; une validation
depuis un réseau Internet extérieur reste à effectuer.
Le [MRpack client publié](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.280/Sanctuary-beta.280.mrpack)
est retéléchargé et vérifié : 16 589 058 octets, hash identique au fichier local,
ZIP intègre, version beta.280, Minecraft 26.3 et un seul override Sanctuary avec
JEI intégré.
Aucune charge réelle avec vingt joueurs, nouvelle connexion OAuth Discord ou
nouvelle qualification graphique n'est revendiquée.
Les sources sont isolées de Map Island Zoom. La prochaine livraison doit
reprendre ce profil serveur et utiliser un compteur supérieur à beta.280.
L'instance Prism personnelle n'a pas été synchronisée par cette mise en service.
Preuves locales supplémentaires : `build/server280-publication.log`,
`build/server280-deployment.log`, `build/server280-gpu-update-validation.log`,
`build/server280-rcon-setup.log`, `build/server280-dhcp-reservation.log`,
`build/server280-upnp-retirement.log` et
les inventaires sous `/root/beta-publication-20261025/` sur PVE. Ces fichiers
restent hors de Git.
## Raccordement Sanctuary Web corrigé le 25 octobre 2026
Le statut et l'Intendance de [Sanctuary Web](https://sanctuary-minecraft.net)
lisaient encore l'ancienne base distante par la connexion Laravel `game`, alors
que Beta écrivait déjà dans la base `sanctuary` de la CT Web 320. La vérification
initiale des écritures serveur ne couvrait donc pas toutes les lectures du site.
Hermes confirme l'identité de cette instance Web ; les anciens sites de release
ne sont pas remis en service.
La connexion `game` utilise maintenant la même base locale que Beta, en gardant
`COMMUNITY_DB_CONNECTION=game` pour les services et les ressources
d'administration. Le cache de configuration Laravel est reconstruit. Les sources
Web déployées, qui contiennent des changements préexistants, restent inchangées.
La connexion Discord fonctionne selon le créateur et n'est pas modifiée.
La nouvelle communauté ne contient aucune publication : Gazette, Tableau et
Intendance affichent désormais cet état, sans suppression dans l'ancienne base.
Les trois comptes, dont deux administrateurs, le groupe d'équipe, ses deux
adhésions et les vingt-deux permissions sont conservés. Les empreintes des
identités, des droits administrateurs et des tables d'équipe sont identiques
avant et après l'opération.
Les services publics et les requêtes d'administration Gazette/Tableau lisent
zéro publication. La page d'accueil et les deux listes publiques répondent HTTP
200. Les métriques sont fraîches, le serveur est déclaré en fonctionnement, et
son temps cumulé continue d'augmenter. Le compteur reste à zéro jour tant que
Beta n'a pas cumulé vingt-quatre heures ; les anciens décès ne sont plus affichés.
La configuration précédente est sauvegardée dans la CT Web, avec accès root
seul, sous
`/root/sanctuary-beta-maintenance/web-game-connection-20261025-1792901058/`.
Le reçu `build/sanctuary-web-beta-alignment.log` et la capture
`build/sanctuary-web-beta-after.png` restent hors de Git. Cette correction de
configuration et de documentation ne change aucun binaire beta.280 ni aucune
sauvegarde Minecraft.
+10
View File
@@ -1,5 +1,15 @@
# Menus Sanctuary — organisation
Évolution courante du 24 octobre 2026 : [Habitant et Pause beta.275](menu-atlas-beta275.md).
La fiche ne contient plus que Factions/Familier en navigation, avec statistiques,
personnage et biographie en trois colonnes. Une fois Carte débloquée, Pause garde
Aventure à gauche, Communauté à droite et Partie sous la carte centrale ; Gazette
et Tableau conservent les colonnes latérales. Les descriptions historiques
ci-dessous et la [première fiche beta.271](inhabitant-menu-beta271.md) restent des références.
Évolution du 24 octobre 2026 : [fiche Habitant et Pause beta.271](inhabitant-menu-beta271.md).
Le cadrage ci-dessous conserve l’organisation historique de beta.083.
Cadrage du 15 septembre 2026, à partir de beta.077.
Branche documentaire initiale : `codex/menu-architecture`.
**Statut : organisation appliquée en [beta.083](menu-organization-beta083.md).
+64
View File
@@ -0,0 +1,64 @@
# MENU-275 — Habitant et Pause autour de la carte
Branche `codex/inhabitant-property`, demande du 24 octobre 2026.
- Retirer le conseil d’arrivée sur la première propriété gratuite.
- Habitant : Factions et Familier en haut, statistiques transparentes à gauche,
personnage au centre avec de l’air, biographie à droite. Retirer le panneau
d’activité et son bouton Historique de cette fiche ; conserver les données.
- Temps joué en jours, heures, minutes et secondes entières, à partir des ticks
Minecraft. Aucune minute décimale. Retour à la ligne en petit format.
- Les propriétés exigent l’aptitude Carte achetée et active. Aucun bouton dans
Habitant ; accès depuis le clic droit sur l’Atlas ou la commande après déblocage.
Le serveur applique cette condition aux commandes, actions et ancien paquet
de carte locale. Les données et protections acquises restent conservées.
- Avant Carte : conserver la disposition actuelle du menu Pause.
- Après Carte : Gazette à gauche, Tableau à droite ; dans la colonne centrale,
Découvertes, Progression, Habitant et Combats au-dessus de la carte, puis Options,
Options du monde et Sauvegarder/quitter ou Déconnexion sous la carte.
Les rubriques restent visibles : **Aventure** forme toujours la colonne de
gauche (Découvertes puis Progression), **Communauté** celle de droite
(Habitant puis Combats), même en grande vue. **Partie / Game** reste au-dessus
des options. Le rectangle de carte remplit l’espace restant, réduit pour
conserver ces titres. Reprendre reste dans le pied natif.
Aucun format de sauvegarde ni génération ne change. Les nouveaux prérequis
n’attribuent ni ne retirent de propriété. Les protections existantes et leur
prédiction de minage s’appliquent aussi aux joueurs sans Carte ; seul l’accès
à leur gestion est verrouillé. La refonte habitation/duels/prix reste en attente.
## Vérification
`check build assemblePack` réussit en **2 min 45 s**, avec **30 tests serveur
ciblés**. Les prérequis de Carte sont testés avant achat, après achat et après
désactivation ; refus sans débit, conservation du claim et des protections,
réactivation et multiplicateurs de minage pour les joueurs sans Carte.
Le format de durée couvre dix limites de ticks et un compteur long.
Le parcours **Vulkan FR/EN GUI 2/3/4** réussit en **56 s**, avec **18 captures**.
Il passe par Hello World réel, vérifie l’absence du conseil de propriété,
la commande bloquée et le paquet de carte locale forgé, puis les trois colonnes
d’Habitant, les unités de temps, la transparence et l’absence d’activité.
Factions et Familier reviennent à la fiche. Les vues Pause avant/après Carte,
les deux colonnes Aventure/Communauté même en grande vue, la rubrique Partie,
les côtés Gazette/Tableau, la disposition des options, leur retour natif et la
mise à jour après désactivation de Carte sont contrôlés. Les textes longs
conservent les défilements et infobulles natifs dans les petits formats.
Captures dans `build/menu275-screenshots/`, avec inspections FR GUI 2/4 de
Pause, FR GUI 4 d’Habitant. Le MRpack beta.275 est exporté et vérifié : ZIP,
Minecraft 26.3, Loader 0.19.5 et octets des deux JAR locaux identiques aux builds.
Les archives beta.270 à beta.274 restent identiques. Reçu :
`build/menu275-validation.json`.
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=menu275,claims273,claims272,property270,mining,overview271,menus,map,demeure \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryMenu275ClientTests=true -PsanctuaryQuickTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
Aucune publication de canal ni synchronisation d’instance personnelle.
+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`.
+47 -4
View File
@@ -1,5 +1,46 @@
# Distribution packwiz et Prism
## beta.280 — JEI embarqué et profil serveur
JEI rejoint le JAR Sanctuary ; `mods/sanctuary.pw.toml` reste le seul artefact
Sanctuary à publier. `python3 scripts/pack.py release <sanctuary-https-url>`
prépare désormais le client et `server/pack.toml`, qui partagent le même JAR,
la même URL et le même hash. Le profil serveur contient seulement Fabric API
et Sanctuary ; Sodium et Sound Physics restent facultatifs côté client.
Les deux profils sont publiés dans le même commit du canal, après vérification
du JAR immuable. L'ancienne entrée JEI est retirée de l'index client.
`./gradlew assembleServerPack` construit un kit Linux avec les outils épinglés
et les commandes de synchronisation/démarrage. [Mode d'emploi](server-pack.md),
[contrat et vérifications](jei-server-beta280.md). La configuration Sanctuary Web,
MariaDB et les secrets restent hors de l'index. La préparation locale ne publie
pas le canal et ne met pas à jour une installation personnelle.
Livraison du 25 octobre : [release beta.280](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.280)
publiée, canal `338e8a62387048c7e703ef036fda759ef57b4130`, puis nouveau serveur
CT 350 sur `gpu` installé avec le kit vérifié. Le monde neuf, les paramètres
et les services Web sont vérifiés dans le [reçu](jei-server-beta280.md).
Une synchronisation du serveur conserve 61 fichiers suivis. L'Alpha et
l'instance Prism personnelle restent hors de cette mise en service.
La règle Bbox permanente est ensuite vérifiée et le secours UPnP Beta retiré.
Le domaine Minecraft `beta.sanctuary-minecraft.net` répond sans ce secours,
avec 20 places. [MRpack client beta.280](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.280/Sanctuary-beta.280.mrpack)
vérifié et disponible ; le [reçu](jei-server-beta280.md) conserve les empreintes
et la limite de validation réseau.
## 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 +2107,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
+22
View File
@@ -50,6 +50,22 @@ flowchart LR
P --> N
```
## CLAIM-278 — Battle consenti pour une propriété
[Le duel de propriété](claim-battle-beta278.md) vise un claim déjà revendiqué :
proposition du challenger, accord explicite du propriétaire, puis préparation
native Battle. Le vainqueur reçoit le chunk avec sa protection restante ; les
résultats et transferts ont un journal de reprise. Le départage d’un terrain
encore libre reste celui de Demeure.
## CLAIM-277 — Habitation avant revendication
[Le lot Demeure / claims](claim-demeure-beta277.md) applique le seuil existant
et l’influence des habitants sur les chunks libres. À égalité, l’ordre des
premières traces départage ; un habitant plus influent peut revendiquer avant
le premier. Les propriétés acquises restent conservées. Les duels et transferts
consentis ne sont pas ajoutés dans ce lot.
## CYCLE-01 — Définir ce qui termine et ce qui recommence
**Branche prévue :** `codex/progression-cycle-contract`.
@@ -299,6 +315,12 @@ ou de prestige ne doit être encaissée deux fois pour un même événement.
## Après ce jalon
Le premier lot [CLAIM-02 / beta.270](property-beta270.md), demandé le
24 octobre 2026, remplace pour l’implémentation actuelle le projet ci-dessous :
propriété personnelle gratuite après Hello World, volume fini en trois
dimensions et agrandissements en XP. Demeure et les factions ne donnent aucun
droit automatique de propriété. Le paragraphe suivant conserve le cadrage historique.
**CLAIM-01** traitera la réservation et la protection des chunks à partir des
factions et d'un contrat dédié : emprise, capacités, droits, conflits, abandon
et incidence du prestige. La grille et Demeure restent des lectures du monde
+95
View File
@@ -0,0 +1,95 @@
# CLAIM-02 — Première propriété d'habitant
Branche `codex/inhabitant-property`, version locale `beta.270`.
La demande du 24 octobre 2026 remplace, pour ce lot, le projet CLAIM-01
de titres temporaires par chunks et d'entretien en saphirs.
## Contrat du premier lot
Après Hello World, chaque habitant peut choisir gratuitement une propriété
personnelle dans l'Overworld. Le centre est la position de ses pieds au moment
de la confirmation. Le volume initial est un cube inclusif de 17 blocs de côté
(rayon de 8 blocs), indépendant des chunks. Quatre extensions ajoutent chacune
4 blocs de rayon dans les trois axes : côtés 25, 33, 41 et 49, pour 100, 200,
300 puis 400 **points** d'XP Minecraft. Le centre ne change pas.
Ce barème est une première hypothèse d'équilibrage, pas une décision définitive.
Une seule propriété par UUID, sans chevauchement en trois dimensions, y compris
les extensions en attente de reprise. Le volume doit rester dans la hauteur
constructible et la bordure du monde. Un cube de rayon 32 autour du spawn commun
reste non revendicable. Aucun déplacement, abandon, transfert, invité, droit de
faction ou remboursement n'est livré dans ce lot. Aucun objet spécial n'est
nécessaire : Habitant → Propriété et `/sanctuary property` (alias `propriete` et
`propriété`) ouvrent le menu ; `claim`, `expand` et `preview` sont aussi disponibles.
L'aperçu affiche les coins exacts et des particules temporaires, sans poser de bloc.
Le titulaire peut modifier son volume. Les autres joueurs, y compris en Créatif,
ne peuvent pas y miner, poser ou utiliser des blocs/objets. Les terrains libres
gardent leurs règles ordinaires. Les contrôles portent aussi sur les cellules
des actions groupées Sanctuary. La protection des dégâts environnementaux
(explosions, pistons, fluides, feu, mobs), des entités et des commandes opérateur
reste hors de ce premier lot et doit être annoncée dans l'interface.
Faction et Battle quittent le menu Pause et rejoignent Habitant. Le moteur de
Battle reste disponible sans nouveau développement. Les deux anciennes places
du menu Pause restent libres pour les futurs boutons.
## Sauvegarde et migration additive
Nouveau fichier `<monde>/sanctuary/properties-v1.json`, schéma 1, lié à la graine
de l'Overworld : `schema`, `seed`, `claims` (UUID → centre et palier), `pending`
(UUID → reçu et extension préparée). Aucun fichier existant ne change de format,
aucun terrain n'est attribué au chargement, aucun chunk n'est régénéré. Les
habitants existants ayant terminé Hello World ont le même droit gratuit à choisir
un emplacement. Demeure reste une mesure d'habitation indépendante.
Écriture atomique avec contrôle de remplacement externe, lecture stricte et
refus de schéma inconnu, conflit ou corruption. Une panne ferme les actions
de propriété et la modification de blocs par les joueurs dans le monde concerné,
sans réinitialiser le registre. Corriger/restaurer le fichier puis redémarrer.
Extension : préparer le journal, débiter l'XP, sauvegarder le joueur avec un
reçu persistant `sanctuary:property_xp_receipt`, vérifier ce reçu sur disque,
puis valider le nouveau volume. À la reconnexion, un reçu présent termine
l'extension ; sinon la préparation est annulée et le volume initial conservé.
Une reprise ne redébite jamais l'XP. Le journal réserve le volume agrandi jusqu'à
la reprise du titulaire. Les requêtes portent le palier attendu pour refuser
les répétitions et les écrans périmés.
## Validation
Le parcours client Vulkan / MoltenVK 1.4.2 sur Apple M1 passe en FR/EN,
GUI 2/3/4, dans un nouveau monde de développement plat de graine **270** :
accès depuis Habitant, absence des anciens boutons dans Pause, allers-retours
Faction/Battle, confirmation de réservation, réservation par le réseau réel,
puis achat d’une extension (150 → 50 points). Dix-neuf captures sont conservées
dans `build/property270-screenshots/` ; les vues compactes FR ont été inspectées.
`check build assemblePack` réussit en **2 min 34 s**, avec les **41 GameTests serveur ciblés**
(propriété, progression, minage, construction et cycles).
Le banc de registre couvre 13 748 assertions, principalement les limites
inclusives 3D, les volumes superposés, les conflits, le plafond, la reprise avec
ou sans reçu, le refus des requêtes périmées et la conservation des fichiers
invalides ou remplacés. Les GameTests utilisent deux joueurs serveur simulés
pour les droits, le minage natif, la pose native, les outils, les interactions,
les limites et le débit d’XP avec vérification du reçu natif sur disque.
Aucun essai avec deux clients distants n’est revendiqué.
Commandes de validation :
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=property270,progression,mining,building,cycle \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryProperty270ClientTests=true -PsanctuaryQuickTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
Journaux ignorés : `build/property270-check-build-pack-final.log` et
`build/property270-client-vulkan.log`. Le pack local reste dans `build/packwiz`.
Export importable vérifié : [Sanctuary-beta.270.mrpack](../build/Sanctuary-beta.270.mrpack)
(intégrité ZIP, versions, dépendances et JAR Sanctuary/JEI identiques aux builds).
Reçu local et empreinte du JAR : `build/property270-validation.json`.
Aucune publication ni synchronisation d’instance personnelle n’a été effectuée.
+78
View File
@@ -0,0 +1,78 @@
# Propriétés — refonte mise de côté, 24 octobre 2026
Retour du créateur après beta.273. Les propositions ci-dessous remplacent le
cadrage futur ; elles ne sont pas annoncées comme des mécaniques implémentées.
La fiche Habitant en deux colonnes et deux rangées est conservée.
## Retour et direction souhaitée
- Mettre de côté le chantier Propriété, jugé encore incertain.
- Le rendu des claims sur la carte paraît superposé et insuffisamment net dans
le style pixel art ; les zones adjacentes ne se raccordent pas comme souhaité.
- S’appuyer sur **Demeure**, le système d’habitation existant. Avoir habité sur
le terrain devrait être nécessaire pour pouvoir le revendiquer ; proposer
la propriété à partir de cette habitation plutôt qu’un choix arbitraire de chunk.
- Le premier habitant serait prioritaire. Lorsque deux habitants veulent le
même endroit, envisager un départage par le système Battle existant ou par
accord commun. La forme exacte du duel et du consentement reste à définir.
- La propriété désigne un **lieu déclaré**. Les joueurs peuvent s’opposer et
se faire la guerre de plusieurs façons ; ce n’est pas une interdiction générale
d’agir sur le terrain.
- Les **64 saphirs** de protection sont jugés trop chers. Aucun nouveau montant
n’a été fixé : ne pas inventer un tarif de remplacement.
- Le graphisme de protection prend trop de place. Direction souhaitée : une
couleur unie, sans gros pictogrammes ni cadres par chunk, accompagnée d’une
légende reliant la couleur au personnage qui a revendiqué le lieu.
- Demeure doit lui aussi se lire comme un calque continu sans bordure interne,
pour percevoir plus facilement les zones d’influence. Retirer « aucun claim
ni protection » de son infobulle, devenu trompeur dans l’ensemble des systèmes.
## Questions de reprise
Le retour n’établit pas encore les seuils d’habitation, l’emprise d’un terrain,
la preuve du premier occupant, les règles d’inactivité, le type de duel, ses
conditions et son consentement, l’accord sans duel, le devenir des claims déjà
achetés, ni le nouveau tarif. Tout changement de registre ou des droits acquis
nécessitera son contrat de migration. Ne pas traiter une victoire au combat
ou la présence Demeure comme un transfert automatique de propriété.
Le créateur a confirmé : **conserver les claims actuels accessibles**. La refonte
des règles est mise en attente ; les claims, achats et protections du prototype
restent disponibles. Aucune suppression des données existantes n’est autorisée.
La passe beta.274 simplifie seulement leur affichage en calque pixel uniforme
pour les protections, sans bordure ni bouclier, avec une légende couleur / propriétaire.
## Évolution d’accès — beta.275
Le créateur demande ensuite de réserver les propriétés aux joueurs ayant acheté
et activé Carte. Le bouton Propriété quitte Habitant ; la commande et le clic
droit de l’Atlas restent utilisables après ce déblocage. Les claims, leurs données
et les protections acquises sont conservés ; le conseil de première arrivée est
retiré. Cela ne met pas en œuvre l’habitation préalable, les duels ou un nouveau prix.
## Évolution d’affichage et de tarif — beta.276
Le créateur fixe maintenant le tarif : **8 saphirs par chunk pour trois jours de
serveur en marche**. Il demande un contour CTM pour toutes les propriétés et
un fond supplémentaire sur les chunks protégés. [Contrat de conservation des
protections et reçus existants](claim-contours-beta276.md). L’habitation préalable
et les duels restent en attente ; ce nouveau tarif remplace uniquement ce point
jusqu’alors ouvert.
## Habitation et concurrence avant claim — beta.277
Le créateur reprend l’habitation préalable et confirme le seuil existant de
Demeure. Il précise ensuite qu’un autre habitant doit pouvoir revendiquer s’il
prend plus d’influence avant le claim du premier : la priorité reste donc
contestable sur le terrain libre. [CLAIM-277](claim-demeure-beta277.md) retient
l’influence effective et, à égalité, l’ordre des premières traces ; un claim
acquis n’est pas transféré automatiquement. Les duels restent à définir.
## Duels de propriété consentis — beta.278
Le créateur demande un duel Battle avec l’accord des deux joueurs et confirme
le périmètre : les propriétés déjà revendiquées. [CLAIM-278](claim-battle-beta278.md)
relie un chunk précis au résultat d’un Battle privé à deux joueurs ; le gagnant
reçoit ce claim et le temps de protection restant. Refus, annulation ou absence
de vainqueur ne transfèrent rien. Les règles des terrains libres de beta.277
restent indépendantes ; aucun duel n’attribue gratuitement un chunk encore libre.
+20
View File
@@ -1,5 +1,25 @@
# CLAIM-01 — Titres de propriété temporaires
[CLAIM-278](claim-battle-beta278.md) permet aux deux joueurs d’accepter un
Battle dont le vainqueur devient propriétaire du claim annoncé.
[CLAIM-277](claim-demeure-beta277.md) relie les revendications au seuil
d’habitation Demeure et à l’influence tant que le chunk reste libre.
[CLAIM-276](claim-contours-beta276.md) actualise le prototype : contours CTM pour
toutes les propriétés, fond sur les claims protégés, **8 saphirs pour trois jours
de serveur par chunk**. Les durées déjà payées sont conservées.
La [direction de refonte du 24 octobre](property-demeure-refonte.md) est mise en
attente. Les claims existants restent accessibles ; habitation préalable, duels
et nouveau tarif ne sont pas encore implémentés.
**Cadrage historique.** Le lot [CLAIM-03 / beta.272](chunk-claims-beta272.md)
retient les claims par chunk depuis la carte, cinq gratuits puis un coût en XP,
et une protection de sept jours de serveur pour 64 saphirs par chunk. Il remplace
le premier modèle en volume [CLAIM-02 / beta.270](property-beta270.md). Les titres
objets et autres intentions ci-dessous restent historiques.
**Ticket local — conception en cours, décisions du 15 septembre 2026.**
Ce document prépare le résultat jouable ; la protection n'est pas implémentée.
Branche réservée : `codex/property-titles`. Le checkout existant reste sur sa
+191
View File
@@ -0,0 +1,191 @@
# PROVIDENCE-265 — Prières et premiers miracles
Demande du 24 octobre 2026, branche `codex/providence-prayers`, livraison
locale **beta.265**, Minecraft **26.3**, Fabric Loader **0.19.5** et
Fabric API **0.160.5+26.3**. Aucune dépendance ajoutée.
## Intégration
Providence est un paquet autonome dans le mod Sanctuary existant. Son
initialisation enregistre deux commandes et une session par serveur. Le
vocabulaire, l'interpréteur, le moteur de décision et le journal utilisent
du Java/Gson indépendant de Minecraft. Seuls la validation du monde,
l'exécution et les réponses dépendent du jeu.
La tranche utilise les services actuels : contrat météo journalier,
position de dernière mort sauvegardée nativement par Minecraft, écritures
atomiques `AtomicJson`, commandes Fabric et ressources FR/EN. Aucun écouteur
du chat général, LLM, appel HTTP ou service extérieur. Les tombes et les
inventaires ne sont jamais interceptés par Providence.
## Jouer
```text
/pray Ô seigneur, j'en ai marre de cet orage, faites revenir le soleil !
/pray Pitié, ramenez-moi à l'endroit où je suis mort
/providence readable true
/providence readable false
```
`/pray` est accessible sans opérateur, dans une partie solo comme sur un
serveur dédié. La prière est limitée à 512 caractères. Elle reçoit une réponse
privée courte, avec deux variantes par verdict. Le mode par défaut utilise
la police `sanctuary:providence`, ressource du mod référençant la police SGA
native `minecraft:alt`. Les textes SGA sont sans accents ; la lecture normale
FR/EN conserve les accents et se choisit individuellement avec `readable`.
Le choix persiste. Les variantes « entendue » sont préparées ; le parcours
jouable répond directement par le verdict final.
Les prières espacées de moins de cinq secondes sont silencieuses, sans
nouvelle recherche de destination ni nouvelle écriture. Une erreur de
persistance ferme le moteur jusqu'à un rechargement valide ; un rechargement
de configuration invalide conserve la session courante. Les données en erreur
sont conservées.
## Compréhension symbolique
Au premier démarrage, le serveur copie deux fichiers embarqués vers :
- `config/sanctuary/providence/concepts.json` : concepts et synonymes, dont
des expressions de plusieurs mots ; vocabulaire français et premiers
synonymes anglais.
- `config/sanctuary/providence/miracles.json` : motifs de reconnaissance,
exclusions, conditions obligatoires, probabilités, cooldowns et rétention.
Après modification, un opérateur utilise `/providence reload`. Les deux
fichiers sont validés avant le remplacement de la session courante. Un
synonyme ajouté ne demande aucune recompilation. Les conditions de sécurité
des exécuteurs ne peuvent pas être supprimées par les fichiers JSON.
La normalisation retire accents, casse et ponctuation ; la détection respecte
les frontières des mots. Une demande doit commencer un segment, invoquer
Providence ou comporter une expression directe comme « ramenez-moi ». Un
constat tel que « le serveur arrête la pluie » reste sans effet. Chaque motif contient des groupes de concepts :
tous les groupes sont requis, avec au moins un concept par groupe. Par
exemple, demande + arrêt + pluie, ou demande + retour + soleil. Le score
de couverture vaut le pourcentage de groupes satisfaits (0 à 100). Le seuil
par défaut est 90 et un motif incomplet ne déclenche jamais d'effet. Ce score
structurel n'est pas une probabilité de compréhension du langage.
Les indices ne sont pas assemblés entre phrases ou propositions séparées par
une conjonction. Les négations, hypothèses et certaines formulations de récit
bloquent la proposition concernée. Deux intentions complètes distinctes
rendent la prière ambiguë et empêchent toute action. Un mot isolé comme
« pluie », un récit de mort ou « J'aime cette pluie, ne l'arrêtez pas »
n'exécute rien.
Ce moteur reconnaît un petit vocabulaire et des relations simples. Il ne
résout pas la coréférence, le sarcasme, les citations imbriquées ou toutes
les tournures françaises. Une prière correcte peut rester incomprise ; les
formulations négatives et hypothétiques sont volontairement conservatrices.
## Miracles et décisions
| Miracle | Politique initiale | Conditions et effet |
| --- | --- | --- |
| `CLEAR_WEATHER` | 35 %, individuel 30 min, global 10 min | Joueur vivant, Overworld avec météo et temps non clair. Profil Sanctuary changé en `CLEAR` jusqu'au prochain jour civil ; prévision normale reprise le lendemain. En mode météo vanilla, éclaircie de 600 secondes configurable. |
| `RETURN_TO_DEATH` | 50 %, individuel 15 min, aucun cooldown global | Joueur vivant, dernière mort connue dans la dimension courante, arrivée sûre dans les chunks déjà chargés. Téléportation native et distance de chute remise à zéro. |
| `RECOVER_ITEMS` | Désactivé, activation JSON refusée | Intention reconnue, aucune lecture ou restitution des objets. Contrat anti-duplication encore à concevoir autour des tombes. |
| `RESCUE_PLAYER` | Désactivé, activation JSON refusée | Intention reconnue, aucune extraction. Les critères de situation bloquée et de destination restent à valider. |
Aucun coût d'XP ou d'objet dans cette tranche. Les probabilités, délais et
activation des deux pouvoirs validés sont configurables. Les délais utilisent
l'heure UTC en millisecondes du serveur ; un recul de son horloge prolonge
l'attente. Un refus probabiliste consomme les mêmes cooldowns qu'un accord,
afin de ne pas relancer le tirage par répétition ou reconnexion. Un refus
de condition, une intention inconnue ou un pouvoir désactivé ne consomme que
la courte limite de fréquence des prières.
Le retour cherche le point sûr le plus proche dans une boîte de ±8 blocs
horizontalement et ±6 verticalement autour de la dernière mort. Le sol doit
être solide, le joueur debout doit tenir dans le volume libre, et les cases voisines
doivent être chargées et dans la bordure. Fluides, lave, feu, cactus, magma,
neige poudreuse, plantes blessantes et portails sont refusés. Aucun bloc
n'est créé ou détruit, aucun chunk n'est chargé ou généré par cette recherche.
Une mort dans le vide, dans une autre dimension ou trop loin des chunks
chargés reste donc sans retour. Une arrivée sûre ne garantit pas l'absence
d'ennemis ou de dangers futurs. Le joueur endormi, mort, spectateur ou passager
est inéligible.
## Nouveau contrat de persistance isolé
`<monde>/data/sanctuary-providence.json`, schéma 1, contient uniquement les
prières explicites admises par la limite de fréquence, leurs UUID, intentions,
scores, ambiguïtés, concepts, conditions examinées, décisions et résultats,
ainsi que les échéances et préférences de lecture. Il n'enregistre pas les
autres messages du chat ou les messages privés entre joueurs.
Par défaut, les 500 dernières entrées au maximum sont conservées pendant
14 jours. Le nettoyage intervient au chargement, à chaque décision et une
fois par minute pendant que le serveur tourne. `historyEntries: 0` désactive
la conservation du texte des prières tout en conservant les protections
contre les répétitions. Les cooldowns non expirés restent indépendants de la
rétention ; les échéances expirées sont éliminées. Les fichiers sont bornés
(configurations 256 Kio chacune, état 8 Mio) et remplacés atomiquement.
Avant tout effet, la décision `PENDING` et ses cooldowns sont enregistrés
(avec la conservation du texte désactivée, seules les réservations persistent).
L'effet réussi devient `GRANTED` avec `executed: true`. Une interruption
entre ces étapes laisse `PENDING` et `executed: false`, résultat incertain
à examiner manuellement ; aucune exécution n'est rejouée au redémarrage.
Il n'existe pas de transaction atomique entre les fichiers du monde Minecraft
et le journal : une panne au milieu d'un effet peut donc laisser ce résultat
incertain. Les refus et échecs ne sont jamais annoncés comme exécutés.
Contrat additif : aucune migration de fichier existant, changement de
génération, expansion, réécriture de chunk ou manipulation de sauvegarde
personnelle. La météo utilise son contrat existant sans en changer le schéma.
## Vérifications
`check build assemblePack` réussit en **2 min 50 s**, sur Java 25.0.3,
avec les **15 GameTests serveur requis réussis** (Providence, tombes et
progression) et **67 vérifications Providence indépendantes de Minecraft**.
Le parcours serveur couvre notamment les vraies commandes, le beau temps
persistant, le cooldown global après rechargement, un rechargement JSON refusé
sans perte de session, le choix d'accessibilité persistant, la position native
après mort et sauvegarde/relecture, la téléportation réelle, les arrivées
non chargées, la lave, le plafond, la pose rampante, le cactus et la restitution
d'objets désactivée. Le journal de l'essai contient une erreur de rechargement
JSON attendue, injectée pour vérifier la conservation de la session.
Les tests indépendants comprennent synonymes rechargés sans compilation,
formulations familières FR/EN, récits sans demande, négations, hypothèses,
ambiguïtés, refus probabiliste, conditions manquantes, pouvoirs désactivés,
limite de fréquence, cooldowns individuels/globaux après redémarrage,
interruption d'exécution, rétention par âge et par nombre, journal textuel
désactivé, isolation des mondes, taille maximale, corruption et conflit
avec une modification extérieure du fichier.
Le parcours client réussit en **1 min 1 s**, sous **Vulkan / MoltenVK 1.4.2,
Apple M1**, avec 8 Gio maximum. Un `/pray` envoyé par la vraie connexion de jeu
arrête l'orage et retourne une réponse SGA ; la commande d'accessibilité
retourne ensuite une réponse en lecture normale. Les huit verdicts principaux
sont rendus dans le chat natif en FR/EN, GUI 2/3/4, en SGA et en lecture normale.
Les **12 captures natives** ont été inspectées : alphabet, ponctuation,
accents et texte contenu dans le chat. Monde de développement neuf, graine 265.
Journaux et captures ignorés :
`build/providence265-check-build-pack-final.log`,
`build/providence265-client-vulkan.log`, `build/providence265-screenshots/`.
Aucun essai OpenGL. Les essais concernent le serveur local et le client macOS ;
aucune connexion distante multijoueur ni validation Windows n'est revendiquée.
Aucune publication, avancée du canal packwiz ou installation personnelle.
Le [MRpack local beta.265](../build/Sanctuary-beta.265.mrpack) est exporté et
vérifié : intégrité ZIP, version, Minecraft 26.3/Fabric exacts, un seul JAR
Sanctuary identique au build, classes compilées et ressources Providence/FR/EN
identiques aux sources, classes de test absentes. Par rapport à beta.264,
les seules classes existantes modifiées sont `SanctuaryMod` et `WeatherService` ;
le reste du nouveau code est dans `providence`. Empreintes et reçu :
`build/providence265-validation.json`.
```sh
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=providence265,graves,progression -PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryProvidence265ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
+53
View File
@@ -0,0 +1,53 @@
# PROVIDENCE-266 — Couleurs des réponses SGA
Demande du 24 octobre 2026, branche `codex/providence-prayers`, livraison locale
**beta.266**, Minecraft **26.3**.
Les réponses de Providence affichées en SGA utilisent les couleurs natives de
Minecraft selon le verdict, pour les deux variantes et les traductions FR/EN :
| Couleur | Réponses |
| --- | --- |
| Vert | Miracle accordé ; confirmation de rechargement si affichée en SGA. |
| Jaune | Prière entendue, cooldown, silence, prière incomprise ou ambiguë. |
| Rouge | Refus, conditions non remplies, pouvoir désactivé, échec d'exécution ou erreur de configuration. |
Le jaune invite à attendre ou à reformuler. Il n'ajoute pas de file d'exécution
automatique. Les messages d'aide et de changement de lecture restent neutres.
Le mode de lecture normale conserve son affichage actuel ; la demande porte
sur le SGA. La police et les textes des verdicts sont conservés.
La modification est limitée à `ProvidenceMessages` et aux métadonnées de
livraison. Les règles de décision et la persistance restent celles de
[Providence beta.265](providence-beta265.md).
## Vérifications
Le parcours existant Providence réussit sous **Vulkan / MoltenVK 1.4.2,
Apple M1**, en **55 secondes**, avec 8 Gio maximum. Il couvre une vraie prière
transmise au serveur, les traductions FR/EN et les échelles GUI 2/3/4, en SGA
et en lecture normale. Douze captures sont conservées dans
`build/providence266-screenshots/` ; les vues SGA FR GUI 2/4 et EN GUI 3 ont
été inspectées, avec les trois couleurs et la police conservée.
Journal : `build/providence266-client-vulkan.log`.
```sh
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryProvidence265ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=providence265,graves,progression -PsanctuaryAtlasOnly=true
```
`check build assemblePack` réussit en **2 min 38 s**, avec les **15 GameTests
serveur requis** et les **67 vérifications indépendantes** de Providence.
Journal : `build/providence266-check-build-pack.log`.
Le [pack local beta.266](../build/Sanctuary-beta.266.mrpack) est exporté et
vérifié : intégrité ZIP, version, Minecraft 26.3, JAR embarqué identique au
build et classes de test absentes. La seule classe modifiée par rapport au
JAR beta.265 est `ProvidenceMessages`. Reçu et empreintes :
`build/providence266-validation.json`. Aucune publication ni mise à jour
d'une installation personnelle.
+103
View File
@@ -0,0 +1,103 @@
# PROVIDENCE-267 — Attendre la réponse de Providence
Demande du 24 octobre 2026, branche `codex/providence-prayers`, livraison locale
**beta.267**, Minecraft **26.3**.
Après une prière admise par `/pray`, Providence attend **5 secondes** avant
d'interpréter la demande, d'examiner le monde, de prendre sa décision et
d'accomplir un éventuel miracle. Aucun verdict ou effet immédiat. Les réponses
conservent les couleurs de beta.266 : vert accordé, jaune attente ou
clarification, rouge refus ou échec.
Une seule prière peut attendre par joueur. Les répétitions pendant ce délai
sont ignorées : elles ne remplacent pas les mots initiaux, ne raccourcissent
pas l'attente et ne déclenchent pas un second tirage. Les autres joueurs
attendent indépendamment. Les cooldowns persistants des miracles et la limite
de fréquence des prières sont conservés ; cette dernière repart du traitement
de la réponse. Le résultat de la commande `/pray` indique désormais l'admission
dans la file, et non l'exécution immédiate d'un miracle.
## Configuration
Ajouter ou modifier dans `config/sanctuary/providence/miracles.json` :
```json
"responseDelaySeconds": 5
```
Valeur entière entre **1 et 60 secondes**, appliquée aux nouvelles prières
après `/providence reload`. Les anciennes configurations beta.265/266 sans
cette clé utilisent cinq secondes automatiquement ; leurs autres valeurs et
leurs fichiers restent conservés. Le champ est fourni dans les configurations
neuves, sans changer le schéma existant.
Une seconde correspond à 20 ticks du monde serveur. Le délai peut donc durer
plus longtemps si le serveur ralentit ou si la partie solo est mise en pause.
Le rechargement de la configuration conserve les prières déjà en attente et
leur échéance initiale. Leur interprétation et les règles utilisées lors de la
décision sont celles de la configuration alors active.
## Cycle de vie et sécurité
L'attente est traitée dans les ticks serveur, sans bloquer de thread. La file
en mémoire est bornée à 1 024 prières et ne survit pas à l'arrêt du serveur.
Une déconnexion ou une réapparition annule la demande en attente ; elle n'est
pas remise à un nouvel objet joueur. Les joueurs déconnectés ou retirés sont
aussi vérifiés avant tout traitement.
Les conditions sont évaluées à l'échéance, puis revérifiées juste avant l'effet
par l'exécuteur existant. Une dernière mort ou une destination ayant changé
pendant l'attente est donc examinée dans son état actuel. Les contrôles d'aide,
de lecture normale et de rechargement restent immédiats.
Les prières sont journalisées quand elles sont traitées. Une attente annulée
avant traitement ne crée pas de décision dans l'historique. La réservation
`PENDING` avant les effets et les règles de non-rejeu de beta.265 sont
conservées. Aucun changement du format des sauvegardes, de la génération ou
des tombes ; aucune installation personnelle utilisée pour les essais.
## Vérifications
Le contrôle client Vulkan réussit en **47 secondes**, sous **MoltenVK 1.4.2 /
Apple M1**, avec 8 Gio maximum, dans un monde de développement neuf graine 265.
Une vraie commande réseau n'affiche aucune réponse immédiate ; le serveur
conserve la pluie pendant l'attente. Un second `/pray bonjour` ne remplace pas
la demande originale. Le miracle et une unique réponse verte arrivent après
**101 ticks serveur** depuis l'envoi, soit environ cinq secondes.
Le parcours FR/EN GUI 2/3/4, en SGA et lecture normale, est conservé. Treize
captures natives : `build/providence267-screenshots/`. La vue d'attente et les
réponses SGA FR GUI 2 / EN GUI 4 ont été inspectées. Journal :
`build/providence267-client-vulkan.log`, marqueur `PROVIDENCE267_DELAY_PASS`.
La suite serveur utilise un délai de test configurable d'une seconde. Elle
vérifie l'absence d'effet et de décision anticipée, les répétitions ignorées,
l'échéance conservée après rechargement, les cooldowns existants, les refus
différés, la téléportation sûre, la dernière mort modifiée pendant l'attente
et l'absence de traitement d'un joueur déconnecté. Le format des fichiers de
configuration beta.265/266 est aussi relu sans réécriture dans les tests purs.
```sh
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=providence265,graves,progression -PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryProvidence265ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
Les essais couvrent le serveur local et le client macOS Vulkan. Aucun essai
distant multijoueur ou Windows, aucune publication ou mise à jour personnelle.
`check build assemblePack` réussit en **2 min 43 s**, avec les **15 GameTests
serveur requis** et **83 vérifications indépendantes** de Providence.
Journal : `build/providence267-check-build-pack.log`.
Le [pack local beta.267](../build/Sanctuary-beta.267.mrpack) est exporté et
vérifié : intégrité ZIP, version et Minecraft 26.3, JAR embarqué identique au
build, configuration et classes Providence identiques aux sorties compilées,
classes de test absentes. Les classes existantes modifiées depuis beta.266
se limitent aux sources `MiracleRegistry` et `ProvidenceService`, classes
internes comprises ; la file `PrayerQueue` est nouvelle. Reçu :
`build/providence267-validation.json`.
+190
View File
@@ -0,0 +1,190 @@
# PROVIDENCE-268 — Les quatorze prières
Demande du 24 octobre 2026, branche `codex/providence-prayers`, livraison locale
**beta.268**, Minecraft **26.3**, Fabric Loader **0.19.5**, Fabric API
**0.160.5+26.3** et Java 25.
Le catalogue compte désormais quatorze miracles actifs. `/pray <texte libre>`
garde son interprétation symbolique locale, ses probabilités, ses conditions
serveur et ses cooldowns persistants. La réponse et l’effet attendent toujours
**cinq secondes**, réglables entre 1 et 60 secondes. Une seule demande attend
par joueur. Le SGA reste **vert** si le miracle est accordé, **jaune** pour
une attente ou une clarification, **rouge** pour un refus ou un échec.
`/providence readable true` conserve la lecture normale FR/EN.
## Catalogue et réglages initiaux
Les durées ci-dessous sont les cooldowns individuels après un tirage admis,
qu’il soit favorable ou refusé. Une condition impossible ne consomme pas ce
tirage. Soleil et Pluie ont chacun un cooldown global supplémentaire de dix
minutes. Les réglages restent modifiables dans
`config/sanctuary/providence/miracles.json`, puis `/providence reload`.
| Nº | Prière | Exemple après `/pray` | Effet accordé | Chance / cooldown |
| --- | --- | --- | --- | --- |
| 01 | Récupération | Rendez-moi mon stuff, je suis mort | Transfère les objets restants de la dernière tombe admissible | 15 % / 60 min |
| 02 | Retour | Ramenez-moi à ma tombe | Arrivée sûre près de la dernière mort, dans la même dimension | 50 % / 15 min |
| 03 | Secours | Ramenez-moi au spawn | Arrivée sûre près du spawn global du serveur | 50 % / 15 min |
| 04 | Soleil | Arrêtez la pluie | Arrête pluie et orage | 35 % / 30 min |
| 05 | Pluie | Faites pleuvoir | Déclenche la pluie | 35 % / 30 min |
| 06 | Guérison | Guéris-moi | Restaure les PV jusqu’au maximum acquis | 50 % / 5 min |
| 07 | Nourriture | Nourris-moi | Restaure faim et saturation jusqu’à la capacité acquise | 50 % / 5 min |
| 08 | Foudre | Foudroie @Pseudo | Éclair visuel et cinq points de dégâts natifs sur la cible | 25 % / 15 min |
| 09 | Malédiction | Maudis @Pseudo | Effet négatif aléatoire pendant vingt secondes | 25 % / 15 min |
| 10 | Vengeance | Punis @Pseudo | Punition aléatoire sur la cible | 25 % / 15 min |
| 11 | Vol | Vole un objet à @Pseudo | Transfère une unité d’un objet aléatoire vers le demandeur | 25 % / 15 min |
| 12 | Désarmement | Désarme @Pseudo | Fait tomber la pile réellement tenue en main principale | 35 % / 10 min |
| 13 | Téléportation | Téléporte-moi près de @Pseudo | Arrivée sûre près du joueur, y compris dans une autre dimension | 50 % / 5 min |
| 14 | Réparation | Répare mon outil | Répare l’objet endommagé tenu en main principale | 50 % / 5 min |
Les noms seuls des prières et les mots isolés ne sont pas des commandes
impératives. Une négation, une hypothèse ou plusieurs intentions complètes
empêchent un effet. Les nouveaux concepts et synonymes FR/EN sont déclaratifs.
Guérison, Nourriture et Réparation visent le demandeur ; elles refusent si
le besoin correspondant n’existe pas. Secours vise le spawn global, pas le lit.
## Joueurs visés et effets
Conformément au choix du créateur, les cinq prières hostiles peuvent viser
**tous les joueurs connectés**, sans accord préalable ni exigence de PvP
activé. La Téléportation utilise le même choix explicite de destinataire.
`@Pseudo` sélectionne un nom complet sans distinction de casse. Un nom complet
sans `@` est aussi reconnu ; `@` évite les collisions avec les mots usuels.
Un nom inconnu, plusieurs cibles ou un alias partagé ne choisissent jamais
un joueur au hasard. Les mentions sont masquées avant la reconnaissance
pour qu’un pseudo comme « Pluie » ne change pas l’intention.
L’UUID est retenu dès la demande, puis la présence de ce même joueur et les
conditions réelles sont revérifiées à l’échéance. Un autre compte arrivé sous
le même nom ne reçoit pas le miracle. La cible doit encore être vivante et
connectée. Une mention explicite sur une prière personnelle est refusée.
Foudre envoie aux clients proches l’éclair natif et applique les dégâts côté
serveur à la seule cible. Aucun éclair n’est ajouté à la simulation serveur :
le décor, le cuivre, les paratonnerres et les voisins restent hors de cet effet.
Les protections et immunités natives aux dégâts restent applicables.
Malédiction tire entre lenteur, faiblesse, fatigue de minage, nausée et faim,
niveau I. Vengeance tire entre cet éclair, cette malédiction, ou quatre points
de dégâts magiques accompagnés du retrait de quatre points de faim.
Vol sélectionne une pile admissible dans les rangées de stockage actuellement
accessibles, hotbar comprise, puis transfère **une unité**, composants conservés.
Équipement porté, main secondaire et stockage verrouillé ne sont pas parcourus.
Une destination pleine ou un vol sur soi-même est refusé. Désarmement dépose
la pile tenue, sans la recopier, avec le délai natif avant ramassage. Un objet
endommagé conserve ses composants et son usure pendant ces transferts.
Les arrivées cherchent un sol, la place debout et l’absence de danger dans
un rayon borné de huit blocs et six blocs verticaux. Elles utilisent uniquement
les chunks déjà chargés, respectent la frontière et remettent la chute à zéro.
Une destination non disponible entraîne un refus. Soleil et Pluie modifient
le profil météo courant jusqu’au prochain minuit civil ; sans contrôleur météo
actif, la durée native configurée vaut dix minutes par défaut.
## Récupération et contrat de sauvegarde additif
Récupération transfère **les objets encore présents**, sans restaurer une copie
de l’inventaire de mort. Elle exige le propriétaire, la position et la dimension
de la dernière mort natives, ainsi que le numéro de décès de la tombe.
Elle recherche les tombes placées dans les neuf chunks déjà chargés autour de
cette mort, les têtes flottantes à proximité (24 blocs horizontaux), et les
propres têtes de tombe portées dans le stockage accessible du demandeur.
Elle ne charge aucun chunk et n’ouvre pas l’inventaire d’un autre joueur.
Les objets déjà retirés ne sont pas recréés. Il faut assez de place pour tout
le contenu admissible ; sinon aucun transfert ni scellement n’a lieu.
Un nouveau registre distinct, `<monde>/data/sanctuary-providence-recoveries.json`,
**schéma 1**, conserve la correspondance entre UUID de tombe consommée et UUID
du bénéficiaire. Son absence signifie qu’aucune récupération Providence n’a
encore été scellée. Il est créé au premier transfert, par remplacement atomique,
avant la mutation des objets. Il est borné à 8 Mio et 100 000 tombes : à capacité
atteinte, les nouvelles récupérations sont refusées. Les sceaux ne sont jamais
purgés, rejoués ou retirés automatiquement.
Ce fichier doit être conservé avec les sauvegardes du monde. Le format des
joueurs et des tombes reste inchangé ; aucune conversion ni réécriture générale
d’un monde n’est nécessaire. Les interfaces et drops d’une tombe scellée
masquent tout ancien contenu, y compris après rechargement d’un vieux chunk
ou d’une ancienne copie de tête. Une corruption du registre bloque l’ouverture
des tombes plutôt que d’effacer le registre.
Les menus de la tombe placée partagent leur conteneur réel ; le propre menu
de tombe du bénéficiaire est fermé avant son transfert.
Le transfert est **au plus une fois**, sans transaction atomique entre tous
les fichiers Minecraft. Une interruption après le scellement mais avant la
sauvegarde du bénéficiaire peut perdre la restitution : le système ne rejoue
pas l’opération et conserve le sceau contre la duplication. Le journal des
prières peut alors garder `PENDING`, avec `executed: false`, résultat incertain
à examiner manuellement. Cette limite complète le contrat de beta.265.
## Configurations existantes
Le chargement ajoute en mémoire les concepts, les nouveaux synonymes des
concepts REQUEST/RAIN/TRAVEL et les dix définitions absentes
d’une ancienne configuration à quatre prières. Les deux anciens emplacements
Récupération/Secours portant exactement `conditions: ["safety_unvalidated"]`
reçoivent leurs nouvelles règles, probabilités et activation validées ; leurs
cooldowns sont conservés. Une définition déjà valide explicitement désactivée
reste désactivée. Les probabilités, cooldowns et dictionnaires existants sont
conservés ; aucun fichier édité n’est réécrit par cette mise à niveau.
Les identifiants des quatre premiers miracles restent stables. Le journal
`sanctuary-providence.json` conserve son schéma et sa rétention. Aucun changement
de génération, de graine, d’expansion ou de monde personnel.
## Vérifications
`check build assemblePack` réussit en **2 min 57 s**, avec les **20 GameTests
serveur requis réussis** et les contrôles purs ci-dessous.
Le parcours client **Vulkan / MoltenVK 1.4.2 / Apple M1** réussit en **1 min 33 s**,
avec 8 Gio maximum et un monde de développement neuf, graine 265. La vraie
commande réseau météo attend **102 ticks serveur** avant le miracle et sa
réponse unique. Une prière ciblée envoyée par la connexion de jeu produit aussi
un éclair natif visible après cinq secondes, avec dégâts vérifiés sur le serveur
et absence d’éclair dans sa simulation. Les réponses SGA et la lecture normale
sont parcourues en FR/EN, GUI 2/3/4. Quatorze captures sont conservées ; les vues
d’attente, SGA FR GUI 2, SGA/lecture normale EN GUI 4 et l’éclair ont été inspectés.
Les contrôles indépendants de Minecraft comptent **146 vérifications** :
quatorze définitions, formulations FR/EN, négations, ambiguïtés, mots isolés,
ciblage exact sans préfixe ni substitution, anciens dictionnaires/configurations,
probabilités, cooldowns, journaux, corruption, écritures concurrentes, sceaux
durables et non-rejeu. Les anciens fichiers personnalisés sont comparés avant
et après chargement.
Les tests serveur couvrent les effets des quatorze miracles, les capacités
acquises de PV/faim/stockage, le ciblage avec PvP désactivé, les transferts réels,
le stockage verrouillé, les tombes placées/portées/flottantes, l’inventaire plein,
les copies de tombe obsolètes après relecture des sceaux, les arrivées sûres,
les commandes différées et l’absence de substitution d’UUID après départ.
Le rechargement d’un JSON volontairement invalide écrit une erreur attendue
pour vérifier que la session valide est conservée.
```sh
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=providence265,providence268,graves,progression \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryProvidence265ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
Les essais utilisent des joueurs simulés côté serveur et une connexion réelle
locale côté client ; ils ne constituent pas un essai avec deux clients distants
ni une validation Windows. Aucun déploiement personnel ou publication.
Journaux et captures ignorés : `build/providence268-check-build-pack-final.log`,
`build/providence268-client-vulkan.log`, `build/providence268-screenshots/`.
Le [pack local beta.268](../build/Sanctuary-beta.268.mrpack) est exporté et
vérifié : ZIP intègre, version et dépendances exactes, un seul JAR Sanctuary
embarqué identique au build, 33 classes Providence identiques aux sorties
compilées, dictionnaires/règles/police/libellés identiques aux sources et
absence de classes de test. Les seules classes existantes modifiées depuis
beta.267 proviennent de `GraveService`, `WeatherService`, `MiracleExecutor`,
`MiracleRegistry`, `MiracleValidator`, `PrayerInterpreter`, `ProvidenceEngine`
et `ProvidenceService`, classes internes comprises. Le registre de sceaux,
les transferts d’inventaire et le ciblage sont de nouveaux composants.
Empreintes et reçu : `build/providence268-validation.json`.
+175
View File
@@ -0,0 +1,175 @@
# PROVIDENCE-269 — Six issues et leurs couleurs
Demande du 24 octobre 2026, branche `codex/providence-prayers`, livraison locale
**beta.269**, Minecraft **26.3**, Java 25 et Fabric Loader **0.19.5**.
Providence dispose de six issues. Les couleurs sont appliquées au **SGA et à
la lecture normale FR/EN**. Le silence ne produit aucun message, même un texte
annonçant que Providence serait silencieuse.
| Verdict | Couleur | Comportement |
| --- | --- | --- |
| ✧ Exaucée | Vert `#55FF55` | Le miracle s’exécute juste avant la réponse favorable |
| ◇ Différée | Jaune `#FFFF55` | Le serveur annonce l’attente et programme réellement le miracle plus tard |
| ✕ Refusée | Rouge `#FF5555` | Aucun miracle n’est accordé |
| ? Incomprise | Gris `#AAAAAA` | La demande n’a pas d’intention reconnue unique |
| ○ Silence | Aucun affichage | Aucun message visible et aucun miracle ; la décision reste journalisée |
| ⌁ Alternative | Violet `#FF55FF` | Une autre aide admissible est réellement accordée au demandeur |
L’attente initiale de **cinq secondes** de beta.267 est conservée. « Exaucée
immédiatement » signifie donc au moment de la réponse, après cette attente.
« Différée » ajoute par défaut **trente secondes après la réponse jaune**.
Une réponse verte est ensuite envoyée si l’effet aboutit. Une condition devenue
impossible produit un refus rouge à cette échéance. Les refus pour cooldown,
pouvoir désactivé ou échec sont rouges ; ils ne programment aucun effet futur.
Une ambiguïté de formulation rejoint le gris de la demande incomprise.
## Politique configurable
Le nouveau fichier `config/sanctuary/providence/responses.json` est créé au
premier chargement, sans réécrire `miracles.json`, `concepts.json` ou un fichier
`responses.json` existant. `/providence reload` recharge les trois fichiers.
Un fichier invalide refuse le rechargement et conserve la session valide.
```json
{
"schema": 1,
"deferredChance": 0.20,
"alternativeChance": 0.25,
"silenceChance": 0.15,
"deferredSeconds": 30,
"alternatives": {
"RECOVER_ITEMS": ["RETURN_TO_DEATH", "RESCUE_PLAYER"],
"HEAL": ["FEED"],
"FEED": ["HEAL"]
}
}
```
Cet extrait est valide ; le fichier distribué contient les quatorze intentions.
Les probabilités sont comprises entre zéro et un, et la somme
`alternativeChance + silenceChance` doit rester inférieure ou égale à un.
`deferredSeconds` est un entier entre **1 et 3 600 secondes**. Chaque seconde
correspond à vingt ticks serveur : pause et ralentissements allongent l’attente.
Le réglage indépendant `responseDelaySeconds` de `miracles.json` reste entre
une et soixante secondes.
La chance propre du miracle demandé est examinée en premier. Parmi les
miracles accordés, 20 % sont différés par défaut, les autres sont exaucés au
moment de la réponse. Pour une décision défavorable, un nouveau tirage alloue
15 % au silence et 25 % à une tentative d’alternative. Une alternative n’est
possible que pour une intention reconnue, active et hors cooldown ; une
intention sans besoin actuel peut aussi recevoir une autre aide. Les demandes
inconnues, ambiguës, désactivées ou en cooldown ne reçoivent pas d’alternative.
Elles peuvent rester silencieuses selon le même réglage.
L’alternative choisit la première aide configurée qui est active, admissible
et hors cooldown. Elle conserve **sa propre probabilité** : un tirage refusé
ne déclenche pas de recherche successive d’un autre miracle. Les cooldowns
du miracle demandé et de cette autre aide sont réservés avant son tirage,
même si celui-ci refuse l’aide. Une autre formulation ou une autre intention
ne permet donc pas de contourner le cooldown de cette aide.
Seules Guérison, Nourriture, Secours, Retour et Réparation peuvent servir
d’alternatives ; elles s’adressent au demandeur. La configuration refuse une
alternative identique à l’intention initiale, un doublon ou un effet ciblé
hostile. Une cible absente, ambiguë ou remplacée n’est pas un prétexte pour
accorder une autre aide. Les cinq prières hostiles demandées explicitement
restent autorisées contre tous les joueurs connectés, comme en beta.268.
Le fichier distribué propose Retour puis Secours à la Récupération, Secours
au Retour, Nourriture à la Guérison, Guérison à la Nourriture, et Guérison puis
Nourriture aux autres intentions. Un joueur sans besoin admissible reçoit
un refus ou du silence.
## Exécution différée et sauvegardes
Une prière en attente de réponse et un miracle différé occupent **la même
place** dans la file : une seule opération par joueur, 1 024 au total.
Les répétitions sont ignorées et ne remplacent pas la demande. Aucun thread
ne dort. Le rechargement conserve l’échéance, l’intention et la cible déjà
retenues ; le miracle n’est pas réinterprété ni tiré à nouveau à son échéance.
Le serveur revérifie alors l’état vivant du demandeur, la présence du même
UUID ciblé, la destination, les objets réels et toutes les conditions du
miracle. Il applique la définition active : désactiver ce miracle avant son
échéance empêche l’effet. Un autre UUID portant le même pseudo ne
remplace jamais la cible. Les arrivées et transferts conservent les protections
de beta.268.
Les cooldowns positifs sont réservés au-delà du rendez-vous annoncé, puis
prolongés si l’exécution réelle survient plus tard. Un cooldown global réglé
à zéro reste désactivé : un joueur qui attend sa guérison ne bloque pas la
guérison des autres joueurs. L’opération ne peut s’exécuter qu’une fois.
Une déconnexion, une réapparition ou l’arrêt normal du serveur annule le
miracle différé, sans effet hors ligne. Les cooldowns réservés sont conservés. Une interruption brutale laisse une
trace `DEFERRED` ou `PENDING`, avec `executed: false` ; les opérations en mémoire
ne sont jamais reconstruites ou rejouées au redémarrage. Comme auparavant,
une interruption pendant un effet peut laisser un résultat incertain.
Le journal `data/sanctuary-providence.json` garde **son schéma 1** et ses champs
existants. Les nouveaux verdicts sont des valeurs du champ texte `decision`.
La complétion ou l’annulation met à jour la même entrée, sans dupliquer la
prière. Une alternative garde l’intention demandée et ajoute une condition
`alternative_<ID>` pour identifier l’aide effectivement tentée.
Le scellement permanent des tombes de beta.268 est inchangé. Aucun monde
personnel, format de tombe, générateur ou identifiant `sanctuary:*` n’est modifié.
## Vérifications
`check build assemblePack` réussit en **2 min 36 s**, avec **191 vérifications
Providence indépendantes de Minecraft** (146 existantes et 45 nouvelles) et
les **22 GameTests serveur requis**. Les scénarios serveur partagés gardent
une politique de test explicite ; le parcours des six nouvelles issues commence
après les scénarios existants pour éviter les rechargements concurrents de leur
configuration commune.
Les nouvelles assertions couvrent les six issues, les cinq couleurs dans les
deux modes, le silence sans message, la réparation/guérison différée, une autre
aide effective, les cooldowns des deux aides y compris un tirage alternatif
refusé, l’absence de blocage global quand le cooldown vaut zéro, les échéances
conservées après rechargement, les cibles par UUID, les besoins modifiés,
la désactivation avant échéance, l’annulation, le non-rejeu, le journal désactivé
et la corruption des configurations. Le JSON invalide injecté dans l’ancien
scénario de rechargement écrit une erreur attendue dans le journal serveur.
Le parcours **Vulkan / MoltenVK 1.4.2 / Apple M1** réussit en **1 min 22 s**,
avec 8 Gio maximum, dans un monde de développement neuf de graine 265.
Les cinq couleurs visibles sont rendues en FR/EN, GUI 2/3/4, SGA et lecture
normale. Les commandes passent par la connexion réelle du jeu. La météo
attend **102 ticks serveur** avant sa réponse ; un éclair ciblé reste visible.
Une nouvelle prière de réparation conserve les cinq secondes d’attente initiale,
puis montre une réponse jaune sans réparation. Avec un délai supplémentaire
de **deux secondes réservé à cet essai**, l’objet reste endommagé pendant
l’attente, puis est réparé et reçoit une réponse verte. Le réglage distribué
reste trente secondes.
Seize captures natives : `build/providence269-screenshots/`. Les vues SGA
FR GUI 2, lecture normale EN GUI 4, réparation différée et réparation terminée
ont été inspectées : couleurs, police, texte contenu dans le chat et ordre des
effets. Les essais concernent macOS local et des joueurs simulés côté serveur ;
aucun essai avec deux clients distants ou sur Windows n’est revendiqué.
```sh
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=providence265,providence268,providence269,graves,progression \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryProvidence265ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
Journaux ignorés : `build/providence269-check-build-pack-final.log` et
`build/providence269-client-vulkan.log`.
Le [pack local beta.269](../build/Sanctuary-beta.269.mrpack) est exporté et vérifié :
ZIP intègre, version et dépendances exactes, un seul JAR Sanctuary identique au
build, 36 classes Providence identiques aux sorties compilées, ressources
identiques aux sources et absence de classes de test. Depuis beta.268, les
classes existantes modifiées proviennent seulement de `PrayerQueue`,
`ProvidenceEngine`, `ProvidenceMessages` et `ProvidenceService`, classes internes
comprises. `ProvidenceResponses` et les types de réservation/exécution sont
nouveaux. Reçu et empreintes : `build/providence269-validation.json`.
Aucune publication, avancée du canal ou installation personnelle.
+100
View File
@@ -0,0 +1,100 @@
# Sanctuary — serveur Linux avec packwiz
Prérequis : Java 25, Python 3.11 ou plus récent, Linux et accès HTTPS sortant.
Plateforme épinglée : Minecraft **26.3**, Fabric Loader **0.19.5**, Fabric API
**0.160.5+26.3**. Le même JAR Sanctuary contient le fork JEI
**30.32.0-sanctuary.4**, son réseau commun et son interface client.
## Préparer le kit
Dans les sources : `./gradlew check build assemblePack assembleServerPack`.
Le kit est `build/Sanctuary-Server-beta.280.zip` ; les manifestes locaux sont
dans `build/packwiz-server/`. Le ZIP est un amorçage : il télécharge les mods
depuis le canal au premier démarrage, il n'est pas une copie hors ligne du pack.
Il n'est utilisable sur le canal public qu'après publication de cette version.
Décompresser le kit **une seule fois**, dans un dossier serveur neuf. Pour
reprendre une installation existante, arrêter le service, sauvegarder le dossier
entier, vérifier Minecraft/Loader et poser uniquement les fichiers du kit en
conservant le monde et la configuration. Ne pas écraser un fichier déjà présent
sans comparaison. Un ancien JEI externe géré par packwiz est retiré par le nouvel
index ; un JAR ajouté manuellement doit être retiré après sauvegarde pour éviter
de charger une autre version du fork. Conserver `config/jei/`.
Canal prévu, publié avec le client dans le même commit de distribution :
```text
https://git.botsu.net/koka/sanctuary-beta/raw/branch/packwiz/server/pack.toml
```
`./server.sh update` synchronise les mods sans démarrer Minecraft.
`./server.sh start` synchronise puis lance Fabric en console. En cas d'échec,
le serveur ne démarre pas. Accepter l'EULA Minecraft et régler le serveur
explicitement avant ouverture aux joueurs ; le kit ne les remplit pas.
Le lanceur Fabric télécharge les fichiers Mojang/Loader nécessaires au lancement.
Les outils de démarrage sont épinglés par hash dans `.tools/platform.json`.
Un changement de Minecraft ou Loader est refusé avant mise à jour et exige un
nouveau kit et un contrat de migration. Le verrou empêche une mise à jour en
parallèle d'un serveur lancé par ce kit. Arrêter aussi tout service historique
qui utiliserait un autre lanceur.
Variables facultatives : `SANCTUARY_JAVA` (chemin du binaire Java),
`SANCTUARY_MEMORY` (`4G` par défaut), `SANCTUARY_PACK_URL` (autre canal ou
serveur HTTP de laboratoire). Les variables sont héritées de l'environnement ;
le script ne charge pas automatiquement un `.env`.
Un exemple systemd est fourni sous `examples/sanctuary-beta.service`.
Installer le kit dans `/srv/minecraft/sanctuary-beta`, créer l'utilisateur
`minecraft`, donner ce dossier à cet utilisateur et placer l'environnement
privé dans `/etc/sanctuary/beta.env` (root, mode 600). Copier ensuite l'unité
dans `/etc/systemd/system/`, recharger systemd et activer le service. Le mode
`KillMode=mixed` transmet l'arrêt au lanceur, qui attend la sauvegarde Java
avant de rendre le verrou de mise à jour.
## Relier Sanctuary Web
L'API d'accueil et la communauté sont deux services existants distincts.
Le pack ne contient ni secret ni configuration de production.
Pour l'accès/candidature, fournir dans l'environnement du service Minecraft :
```text
SANCTUARY_APP_URL=https://sanctuary-minecraft.net
SANCTUARY_API_TOKEN=<jeton API du site>
```
Le site doit fournir les POST authentifiés `/api/v1/access-codes/verify` et
`/redeem`, avec les reçus d'admission de beta.166. Aucun appel d'essai ne doit
consommer un vrai code de joueur. Sans ces deux paramètres, le mod autonome
laisse l'accueil sans code ; une configuration partielle ferme l'accueil.
Pour Gazette/Tableau et les statistiques, reprendre le **serverId existant**
dans `config/sanctuary-community.json`, configurer `mode: database`, le JDBC
MariaDB, l'utilisateur et `passwordEnv: SANCTUARY_COMMUNITY_DB_PASSWORD`.
Le mot de passe est uniquement dans l'environnement. Utiliser le même UUID dans
`SANCTUARY_COMMUNITY_SERVER_ID` côté Laravel. L'exemple sous `examples/` doit être
complété, puis copié volontairement ; il ne remplace aucune configuration.
Garder `online-mode=true` pour l'identité et la liaison des comptes.
Les migrations SQL sont celles du site et doivent être vérifiées sur la base
cible avant activation. Basculer du mode fichier vers MariaDB n'importe pas les
anciennes publications : cela demande un contrat de migration séparé.
Le kit n'effectue aucune migration SQL ni modification de sauvegarde.
## Mises à jour et retour arrière
Arrêter le service, sauvegarder, lancer `./server.sh update`, vérifier les logs,
puis redémarrer le même dossier serveur. `packwiz.json` doit rester présent.
Le pack ne possède que les mods Fabric API et Sanctuary. Il ne gère ni monde,
`server.properties`, `eula.txt`, `config/`, listes de joueurs, permissions,
captures ou secrets. Les réglages des opérateurs restent en place.
Sauvegarder aussi l'identifiant d'admission et ses reçus avec le monde, ainsi
que les configurations et l'UUID communautaire. Un retour arrière reprend le
dernier artefact immuable et la sauvegarde correspondant à son contrat de
données ; ne pas supprimer de journaux pour forcer le chargement.
Référence : [installation packwiz sur serveur](https://packwiz.infra.link/tutorials/installing/packwiz-installer/#using-a-modpack-with-a-server).
Dans le dépôt, consulter aussi `docs/inscription-web-beta166.md` et
`docs/community-contract-v1.md` pour les contrats et droits SQL complets.
+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`).
+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é.
+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.250
pack_version=beta.250
mod_version=beta.280
pack_version=beta.280
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
+9 -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,11 @@ 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.280, JEI est de nouveau embarqué dans Sanctuary, avec ses métadonnées
Fabric, ses entrées communes/cliente et son pont de catalogue. Il n'est plus
distribué séparément dans packwiz. La séparation facultative beta.257–278
n'a modifié aucune connaissance serveur ; ces données restent conservées.
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 +51,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;}
});
});
}
}
@@ -15,6 +15,7 @@ public final class QuickTestClient implements ClientModInitializer {
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)->{
@@ -20,6 +20,7 @@ public final class QuickTestMod implements ModInitializer {
HordeLab.register();
PalaceLab.register();
InsectLab.register();
FamiliarLab.register();
WorldgenDiagnostics.register();
PayloadTypeRegistry.clientboundConfiguration().register(Mode.TYPE,Mode.CODEC);
ServerConfigurationConnectionEvents.CONFIGURE.register((handler,server)->{
+149 -3
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'))
@@ -114,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) {
@@ -188,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)
@@ -229,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 = [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']
def focusClasses = [claims278: 'ClaimBattle278GameTests', claims277: 'ClaimDemeure277GameTests', menu275: 'Menu275GameTests', claims273: 'ClaimMap273GameTests', claims272: 'ChunkClaims272GameTests', overview271: 'Inhabitant271GameTests', property270: 'Property270GameTests', providence269: 'Providence269GameTests', providence268: 'Providence268GameTests', providence265: 'Providence265GameTests', 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)
@@ -375,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') }
@@ -434,6 +486,41 @@ 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 providence265Client = providers.gradleProperty('sanctuaryProvidence265ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryProvidence265ClientTests', providence265Client)
if (providence265Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Providence265ClientChecks') }
def claims278Client = providers.gradleProperty('sanctuaryClaims278ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryClaims278ClientTests', claims278Client)
if (claims278Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.ClaimBattle278ClientChecks') }
def claims277Client = providers.gradleProperty('sanctuaryClaims277ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryClaims277ClientTests', claims277Client)
if (claims277Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.ClaimDemeure277ClientChecks') }
def claims276Client = providers.gradleProperty('sanctuaryClaims276ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryClaims276ClientTests', claims276Client)
if (claims276Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.ClaimMap276ClientChecks') }
def menu275Client = providers.gradleProperty('sanctuaryMenu275ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryMenu275ClientTests', menu275Client)
if (menu275Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Menu275ClientChecks') }
def map274Client = providers.gradleProperty('sanctuaryMap274ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryMap274ClientTests', map274Client)
if (map274Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Map274ClientChecks') }
def demeure274Client = providers.gradleProperty('sanctuaryDemeure274ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryDemeure274ClientTests', demeure274Client)
if (demeure274Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Demeure274ClientChecks') }
def claims273Client = providers.gradleProperty('sanctuaryClaims273ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryClaims273ClientTests', claims273Client)
if (claims273Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.ClaimMap273ClientChecks') }
def claims272Client = providers.gradleProperty('sanctuaryClaims272ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryClaims272ClientTests', claims272Client)
if (claims272Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.ChunkClaims272ClientChecks') }
def inhabitant271Client = providers.gradleProperty('sanctuaryInhabitant271ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryInhabitant271ClientTests', inhabitant271Client)
def property270Client = providers.gradleProperty('sanctuaryProperty270ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryProperty270ClientTests', property270Client)
if (property270Client || inhabitant271Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Property270ClientChecks') }
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') }
@@ -583,9 +670,23 @@ 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')
compileOnly project(':jei')
if (providers.gradleProperty('sanctuaryJeiRuntime').map { it.toBoolean() }.getOrElse(true)) {
runtimeOnly project(':jei')
}
// One distributed Sanctuary JAR; keep the exact JEI fork and its own metadata.
include project(':jei')
minecraft "com.mojang:minecraft:${rootProject.minecraft_version}"
implementation "net.fabricmc:fabric-loader:${rootProject.loader_version}"
@@ -620,7 +721,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) }
}
@@ -628,6 +730,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) {
@@ -1407,3 +1510,46 @@ tasks.register('exportStandaloneLaunch') {
}
}
}
// Providence's interpreter, decisions and persistence run independently of Minecraft.
tasks.register('providence265Smoke', JavaExec) {
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.providence.Providence265Smoke'
}
tasks.named('check') { dependsOn('providence265Smoke') }
tasks.register('providence269Smoke', JavaExec) {
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.providence.Providence269Smoke'
}
tasks.named('check') { dependsOn('providence269Smoke') }
tasks.register('property270Smoke', JavaExec) {
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.property.Property270Smoke'
}
tasks.named('check') { dependsOn('property270Smoke') }
tasks.register('chunkClaims272Smoke', JavaExec) {
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.property.ChunkClaims272Smoke'
}
tasks.named('check') { dependsOn('chunkClaims272Smoke') }
tasks.register('playTime275Smoke', JavaExec) {
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.inhabitant.PlayTime275Smoke'
}
tasks.named('check') { dependsOn('playTime275Smoke') }
tasks.register('claimDuel278Smoke', JavaExec) {
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.property.ClaimDuel278Smoke'
}
tasks.named('check') { dependsOn('claimDuel278Smoke') }
@@ -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,66 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.property.*;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.network.chat.contents.TranslatableContents;
public final class ChunkClaims272ClientChecks implements FabricClientGameTest {
private static final KeyEvent ENTER=new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);
public void runTest(ClientGameTestContext c){
Starter039TestSupport.manual=true;var onboarding=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen s){
Starter039TestSupport.selectFirst(s);s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(ENTER);
button(s,"sanctuary.progression.enter").onPress(ENTER);onboarding.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Claims272 "+UUID.randomUUID());s.setSeed("272");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(128,-40,128);p.setNoGravity(true);s.setDifficulty(net.minecraft.world.Difficulty.PEACEFUL,true);});
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.getConnection().sendCommand("sanctuary property");});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.renderedPixelAt(152,136)!=0,400);
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.atlas.recenter").onPress(ENTER);button(m.gui.screen(),"sanctuary.atlas.recenter").onPress(ENTER);});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.isComplete()&&map.status().equals("ready"),400);
c.runOnClient(m->{require(!ProgressionClient.progress.atlas(),"Property map does not grant the Atlas aptitude");rightClick(m.gui.screen());});
c.waitFor(m->view(m.gui.screen())!=null,200);
c.runOnClient(m->{var d=view(m.gui.screen());require(d.eligible()&&d.count()==0&&d.cost()==0,"Hello World admits free chunk selection");});
c.waitTicks(3);c.takeScreenshot("claims272-map-free");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.claims.claim_at_cost").onPress(ENTER));
c.waitFor(m->view(m.gui.screen())!=null&&view(m.gui.screen()).count()==1,200);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().setItem(9,new net.minecraft.world.item.ItemStack(fr.koka.sanctuary.gems.Gems.SAPPHIRE.gem(),64));p.inventoryMenu.broadcastChanges();});
c.waitTicks(5);c.runOnClient(m->{m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));rightClick(m.gui.screen());});
c.waitFor(m->view(m.gui.screen())!=null&&view(m.gui.screen()).sapphires()==64,200);
c.waitTicks(3);c.takeScreenshot("claims272-map-sapphires");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.claims.protect_at_cost").onPress(ENTER));
c.waitFor(m->view(m.gui.screen())!=null&&view(m.gui.screen()).message().equals("protected"),200);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();require(ChunkPropertyService.sapphires(p)==0,"Map action debits full stack");p.setExperienceLevels(100);require(ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()),"Purchase Atlas normally");ProgressionService.send(p,"");});
c.waitFor(m->ProgressionClient.progress.atlas(),200);c.waitTicks(30);
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 SanctuaryAtlasScreen(null));});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.renderedPixelAt(152,136)!=0,400);
c.runOnClient(m->rightClick(m.gui.screen()));
c.waitFor(m->view(m.gui.screen())!=null,200);
c.runOnClient(m->{var d=view(m.gui.screen());require(d.x()==9&&d.z()==8,"Ordinary Atlas right-click selects actual chunk without a mode toggle");widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.visible).forEach(b->require(b.getX()>=0&&b.getRight()<=m.gui.screen().width&&b.getBottom()<=m.gui.screen().height,"Visible controls fit"));});
c.waitTicks(3);c.takeScreenshot("claims272-map-protected-"+locale+"-gui"+scale);
c.runOnClient(m->{m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));require(m.gui.screen() instanceof SanctuaryAtlasScreen&&view(m.gui.screen())==null,"Escape closes the context panel and keeps the map");});
}
c.runOnClient(m->m.gui.setScreen(null));
}finally{Starter039TestSupport.manual=false;}
}
private static ChunkPropertyPayloads.View view(Screen screen){return screen instanceof SanctuaryAtlasScreen map?map.propertyView():null;}
private static void rightClick(Screen screen){var atlas=(SanctuaryAtlasScreen)screen;require(atlas.mouseClicked(new MouseButtonEvent(atlas.width/2.+48,(atlas.contentTop+atlas.contentBottom)/2.+16,new MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_RIGHT,0)),false),"Right click is handled on the terrain");}
private static String key(AbstractWidget w){return w.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
private static java.util.stream.Stream<GuiEventListener> widgets(ContainerEventHandler c){return c.children().stream().flatMap(w->w instanceof ContainerEventHandler nested?java.util.stream.Stream.concat(java.util.stream.Stream.of(w),widgets(nested)):java.util.stream.Stream.of(w));}
private static Button button(Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals(key)).findFirst().orElseThrow(()->new AssertionError("Missing button "+key));}
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,117 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.arena.*;
import fr.koka.sanctuary.property.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.gems.Gems;
import fr.koka.demeure.DemeureService;
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.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.*;
/** Real claim proposals through the network, explicit owner consent and native Battle deaths. */
public final class ClaimBattle278ClientChecks implements FabricClientGameTest {
private static final KeyEvent ENTER=new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);
private final List<ServerPlayer> guests=new ArrayList<>();
private final AtomicReference<ClaimDuels.Offer> offer=new AtomicReference<>();
private final AtomicReference<ServerPlayer> opponent=new AtomicReference<>();
private long paid;private final AtomicLong nextBattleAt=new AtomicLong();
public void runTest(ClientGameTestContext c){
Starter039TestSupport.manual=true;var intro=new AtomicBoolean(true);var key=new ChunkClaims.Key(9,8);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen s){Starter039TestSupport.selectFirst(s);s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(ENTER);button(s,"sanctuary.progression.enter").onPress(ENTER);intro.set(false);}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("ClaimBattle278 "+UUID.randomUUID());s.setSeed("278");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
var server=world.getServer();
server.runOnServer(s->{var p=host(s);p.teleportTo(128,4,128);p.setNoGravity(true);s.setDifficulty(net.minecraft.world.Difficulty.PEACEFUL,true);p.setExperienceLevels(20);require(ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()),"Purchase actual Atlas");ProgressionService.send(p,"");claim(s,p,key);var guest=guest(p,"Nora");opponent.set(guest);inhabit(p,key,1);inhabit(guest,key,1);require(ClaimDuels.propose(guest,key,ChunkPropertyService.store(s).revision()).equals("sent"),"Remote challenger explicitly proposes the claim stake");offer.set(ClaimDuels.at(s,key));});
c.waitFor(m->ProgressionClient.progress.atlas(),200);
// Owner reaches the review using the existing map context.
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.getConnection().sendCommand("sanctuary property");});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.claimOverlay.get(9,8)!=null,400);
c.runOnClient(m->rightClick((SanctuaryAtlasScreen)m.gui.screen(),152,136));c.waitFor(m->((SanctuaryAtlasScreen)m.gui.screen()).propertyView()!=null,200);
c.waitTicks(3);c.runOnClient(m->button(m.gui.screen(),"sanctuary.claim_duel.map_button").onPress(ENTER));
c.waitFor(m->m.gui.screen() instanceof ClaimDuelScreen&&dataOffer("OFFER"),200);
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 ClaimDuelScreen(null,key));});
c.waitTicks(3);c.takeScreenshot("claim278-owner-terms-"+locale+"-gui"+scale);
c.runOnClient(m->{require(button(m.gui.screen(),"sanctuary.claim_duel.accept").active,"Owner has an explicit property acceptance button");require(widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w->key(w).equals("sanctuary.claim_duel.consequences")),"Property transfer and real death consequences are visible before agreement");bottom(m.gui.screen());});
c.waitTicks(3);c.takeScreenshot("claim278-owner-consent-"+locale+"-gui"+scale);
}
c.runOnClient(m->button(m.gui.screen(),"sanctuary.claim_duel.decline").onPress(ENTER));
c.waitFor(m->ClaimDuelClient.data!=null&&ClaimDuelClient.data.has("offer")&&ClaimDuelClient.data.getAsJsonObject("offer").get("result").getAsString().equals("declined"),200);
server.runOnServer(s->{require(Arenas.match(host(s))==null&&ChunkPropertyService.store(s).get(key).owner().equals(host(s).getUUID()),"Real decline packet starts no fight and changes no claim");var guest=guest(host(s),"Camille");opponent.set(guest);inhabit(guest,key,1);require(ClaimDuels.propose(guest,key,ChunkPropertyService.store(s).revision()).equals("sent"),"Fresh challenger proposes a new immutable offer");offer.set(ClaimDuels.at(s,key));});
c.runOnClient(m->ClaimDuelClient.open(null,key));c.waitFor(m->dataOffer("OFFER")&&ClaimDuelClient.data.getAsJsonObject("offer").get("id").getAsString().equals(offer.get().id.toString()),200);
c.waitTicks(3);c.runOnClient(m->{bottom(m.gui.screen());button(m.gui.screen(),"sanctuary.claim_duel.accept").onPress(ENTER);});
c.waitFor(m->dataOffer("OPEN"),200);c.waitTicks(3);c.takeScreenshot("claim278-mutual-agreement");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.claim_duel.battle").onPress(ENTER));
c.waitFor(m->m.gui.screen() instanceof ArenaScreen&&ArenaClient.data.has("match"),200);
c.waitTicks(3);
c.runOnClient(m->{require(ArenaClient.data.getAsJsonObject("match").has("claim"),"Battle prominently carries the exact property stake");bottom(m.gui.screen());button(m.gui.screen(),"sanctuary.arena.ready").onPress(ENTER);});
c.waitFor(m->ArenaClient.data.has("match")&&ArenaClient.data.getAsJsonObject("match").get("ready").getAsBoolean(),200);
server.runOnServer(s->{var m=offer.get().match;Arenas.ready(m,opponent.get());require(Arenas.start(m,opponent.get()).equals("started"),"Both players ready before the challenger launches native Battle");m.until=FamiliarBattle.now();Arenas.tick(s);opponent.get().setInvulnerableTime(0);require(opponent.get().hurtServer(opponent.get().level(),opponent.get().damageSources().playerAttack(host(s)),100),"Owner wins through native player damage");require(m.phase==Arenas.Phase.FINISHED&&m.winner.equals(host(s).getUUID())&&ChunkPropertyService.store(s).get(key).owner().equals(host(s).getUUID()),"Defender victory preserves existing ownership");nextBattleAt.set(FamiliarBattle.now()+FamiliarBattle.COMBAT_MILLIS+100);});
// Respect Battle's existing post-combat timer before a new dispute.
c.waitFor(m->System.currentTimeMillis()>=nextBattleAt.get(),600);
// Now the real client challenges someone else's protected chunk.
var second=new ChunkClaims.Key(10,8);
server.runOnServer(s->{require(!FamiliarBattle.fighting(host(s)),"Native post-combat cooldown elapsed normally");var guest=guest(host(s),"Benoit");opponent.set(guest);claim(s,guest,second);paid=ChunkPropertyService.store(s).get(second).until();inhabit(host(s),second,1);inhabit(guest,second,1);var p=host(s);require(ClaimDuels.canPropose(p,second).isEmpty(),"Second duel setup: reason="+ClaimDuels.canPropose(p,second)+" alive="+p.isAlive()+" removed="+p.isRemoved()+" creative="+p.isCreative()+" spectator="+p.isSpectator()+" arena="+Arenas.enrolled(p)+" familiarDuel="+(FamiliarDuels.match(p)!=null)+" fighting="+FamiliarBattle.fighting(p)+" pendingSelf="+(ChunkPropertyService.store(s).pending(p.getUUID())!=null)+" pendingOwner="+(ChunkPropertyService.store(s).pending(guest.getUUID())!=null)+" selectable="+ChunkPropertyService.selectable(p,second));});
c.runOnClient(m->m.gui.setScreen(new SanctuaryAtlasScreen(null,new fr.koka.sanctuary.community.CommunityTask.Location("minecraft:overworld",160,4,136))));
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.claimOverlay.get(10,8)!=null,400);
c.runOnClient(m->rightClick((SanctuaryAtlasScreen)m.gui.screen(),168,136));c.waitFor(m->((SanctuaryAtlasScreen)m.gui.screen()).propertyView()!=null,200);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.claim_duel.map_button").onPress(ENTER));c.waitFor(m->m.gui.screen() instanceof ClaimDuelScreen&&ClaimDuelClient.data!=null&&!ClaimDuelClient.data.has("offer"),200);
c.waitTicks(3);c.runOnClient(m->{bottom(m.gui.screen());require(button(m.gui.screen(),"sanctuary.claim_duel.propose").active,"Challenger proposal must be available: "+ClaimDuelClient.data);button(m.gui.screen(),"sanctuary.claim_duel.propose").onPress(ENTER);});c.waitFor(m->dataOffer("OFFER")||!ClaimDuelClient.data.get("message").getAsString().isEmpty(),200);c.runOnClient(m->require(dataOffer("OFFER"),"Proposal response: "+ClaimDuelClient.data));
c.waitTicks(3);c.takeScreenshot("claim278-challenger-waiting-owner");
server.runOnServer(s->{offer.set(ClaimDuels.at(s,second));require(Arenas.match(host(s))==null,"Client proposal alone cannot force its opponent into Battle");require(ClaimDuels.accept(opponent.get(),offer.get().id).equals("accepted"),"Other property owner independently accepts the exact proposal");});
c.waitFor(m->dataOffer("OPEN"),200);c.runOnClient(m->button(m.gui.screen(),"sanctuary.claim_duel.battle").onPress(ENTER));
c.waitFor(m->m.gui.screen() instanceof ArenaScreen&&ArenaClient.data.has("match"),200);
c.waitTicks(3);c.runOnClient(m->{bottom(m.gui.screen());button(m.gui.screen(),"sanctuary.arena.ready").onPress(ENTER);});
c.waitFor(m->ArenaClient.data.has("match")&&ArenaClient.data.getAsJsonObject("match").get("ready").getAsBoolean(),200);
server.runOnServer(s->Arenas.ready(offer.get().match,opponent.get()));
c.waitFor(m->ArenaClient.data.getAsJsonObject("match").getAsJsonArray("entrants").asList().stream().allMatch(v->v.getAsJsonObject().get("ready").getAsBoolean()),200);
for(String locale:List.of("fr_fr","en_us")){
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(2);m.resizeGui();m.gui.setScreen(new ArenaScreen(null));});
c.waitTicks(3);c.takeScreenshot("claim278-battle-stake-"+locale);
}
c.waitTicks(3);c.runOnClient(m->{bottom(m.gui.screen());button(m.gui.screen(),"sanctuary.arena.start").onPress(ENTER);});
c.waitFor(m->ArenaClient.data.getAsJsonObject("match").get("phase").getAsString().equals("COUNTDOWN"),200);
server.runOnServer(s->{offer.get().match.until=FamiliarBattle.now();Arenas.tick(s);});
c.runOnClient(m->m.gui.setScreen(new SanctuaryAtlasScreen(null,new fr.koka.sanctuary.community.CommunityTask.Location("minecraft:overworld",160,4,136))));
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.claimOverlay.get(10,8)!=null&&map.claimOverlay.get(10,8).claim().owner().equals(opponent.get().getUUID()),400);
c.waitTicks(3);c.takeScreenshot("claim278-map-before-result");
server.runOnServer(s->{var p=host(s);var loser=opponent.get();loser.setInvulnerableTime(0);require(loser.hurtServer(loser.level(),loser.damageSources().playerAttack(p),100),"Native challenger victory");var claim=ChunkPropertyService.store(s).get(second);require(claim.owner().equals(p.getUUID())&&claim.until()==paid,"Battle transfers ownership and preserves the exact paid expiry");require(ChunkPropertyService.store(s).get(key).owner().equals(p.getUUID()),"Other property is unchanged");});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.claimOverlay.get(10,8).claim().owner().equals(m.player.getUUID()),200);
c.waitTicks(3);c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();require(map.claimOverlay.get(9,8).claim().color()==map.claimOverlay.get(10,8).claim().color(),"Transferred claim uses winner's persistent color");require(map.claimOverlay.ink(9,8).pixel(15,8)==map.claimOverlay.tint(9,8),"CTM removes the former ownership boundary");});
c.takeScreenshot("claim278-map-live-transfer");
server.runOnServer(s->{for(var p:guests){var m=Arenas.match(p);if(m!=null)Arenas.finish(m,null,"interrupted");s.getPlayerList().getPlayersByUUID().remove(p.getUUID());p.discard();}});
c.runOnClient(m->m.gui.setScreen(null));
}finally{Starter039TestSupport.manual=false;intro.set(false);guests.clear();}
}
private ServerPlayer guest(ServerPlayer host,String name){
var s=host.level().getServer();var profile=new com.mojang.authlib.GameProfile(UUID.randomUUID(),name);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.setGameMode(net.minecraft.world.level.GameType.SURVIVAL);p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);p.setHealth(6);p.setNoGravity(true);p.setPos(host.getX()+4,host.getY(),host.getZ()+2);p.getAbilities().invulnerable=false;p.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());
var identities=ProgressionService.session(s).inhabitants;try{require(identities.create(p.getUUID(),name,"",identities.available().getFirst()),"Create guest identity");identities.arrive(p.getUUID());}catch(Exception e){throw new RuntimeException(e);}
p.setExperienceLevels(20);require(ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()),"Guest has real Atlas");s.getPlayerList().getPlayersByUUID().put(p.getUUID(),p);guests.add(p);return p;
}
private static void claim(MinecraftServer s,ServerPlayer p,ChunkClaims.Key key){try{p.level().getChunk(key.x(),key.z());var c=ChunkPropertyService.store(s);var tx=c.prepare(p.getUUID(),"claim",key,c.revision());c.recover(p.getUUID(),tx.receipt().toString());p.getInventory().setItem(9,new net.minecraft.world.item.ItemStack(Gems.SAPPHIRE.gem(),8));require(ChunkPropertyService.act(p,"protect",key,c.revision()).equals("protected"),"Pay eight sapphires for actual protection");}catch(Exception e){throw new RuntimeException(e);}}
private static void inhabit(ServerPlayer p,ChunkClaims.Key key,double amount){DemeureService.record(p,new BlockPos(key.x()*16,-40,key.z()*16),amount);}
private static ServerPlayer host(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static boolean dataOffer(String phase){return ClaimDuelClient.data!=null&&ClaimDuelClient.data.has("offer")&&ClaimDuelClient.data.getAsJsonObject("offer").get("phase").getAsString().equals(phase);}
private static void rightClick(SanctuaryAtlasScreen map,int x,int z){map.mouseClicked(new MouseButtonEvent(map.width/2.+(x-map.centerX())*map.zoom(),(map.contentTop+map.contentBottom)/2.+(z-map.centerZ())*map.zoom(),new MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_RIGHT,0)),false);}
private static void bottom(Screen s){widgets(s).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).forEach(w->w.setScrollAmount(10000));}
private static String key(AbstractWidget w){return w.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
private static java.util.stream.Stream<GuiEventListener> widgets(ContainerEventHandler c){return c.children().stream().flatMap(w->w instanceof ContainerEventHandler nested?java.util.stream.Stream.concat(java.util.stream.Stream.of(w),widgets(nested)):java.util.stream.Stream.of(w));}
private static Button button(Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals(key)).findFirst().orElseThrow(()->new AssertionError("Missing button "+key));}
private static void require(boolean yes,String why){if(!yes)throw new AssertionError(why);}
}
@@ -0,0 +1,179 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.property.*;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.network.chat.contents.TranslatableContents;
public final class ClaimDemeure277ClientChecks implements FabricClientGameTest {
private static final KeyEvent ENTER=new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);
public void runTest(ClientGameTestContext c){
Starter039TestSupport.manual=true;var onboarding=new AtomicBoolean(true);var neighbour=new java.util.concurrent.atomic.AtomicReference<net.minecraft.server.level.ServerPlayer>();
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen s){
Starter039TestSupport.selectFirst(s);s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(ENTER);
button(s,"sanctuary.progression.enter").onPress(ENTER);onboarding.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("ClaimDemeure277 "+UUID.randomUUID());s.setSeed("277");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(128,-40,128);p.setNoGravity(true);s.setDifficulty(net.minecraft.world.Difficulty.PEACEFUL,true);});
world.getServer().runOnServer(server->{var p=server.getPlayerList().getPlayers().getFirst();p.setExperienceLevels(20);require(ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()),"Purchase actual Atlas before accessing properties");ProgressionService.send(p,"");});
c.waitFor(m->ProgressionClient.progress.atlas(),200);
checkCache();
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.getConnection().sendCommand("sanctuary property");});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.renderedPixelAt(152,136)!=0,400);
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.atlas.recenter").onPress(ENTER);button(m.gui.screen(),"sanctuary.atlas.recenter").onPress(ENTER);});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.isComplete()&&map.status().equals("ready"),400);
c.runOnClient(m->{require(ProgressionClient.progress.atlas(),"Property management requires the purchased Atlas");rightClick(m.gui.screen());});
c.waitFor(m->view(m.gui.screen())!=null,200);
c.runOnClient(m->{var d=view(m.gui.screen());require(d.eligible()&&d.count()==0&&d.cost()==0,"Hello World admits free chunk selection");});
c.waitFor(m->residency(m.gui.screen())!=null,200);
c.runOnClient(m->{require(residency(m.gui.screen()).reason().equals("habitation_required")&&!button(m.gui.screen(),"sanctuary.claims.claim_at_cost").active,"Empty land cannot be claimed before habitation, even for free");});
c.waitTicks(3);c.takeScreenshot("claims277-habitation-required");
world.getServer().runOnServer(server->{var p=server.getPlayerList().getPlayers().getFirst();fr.koka.demeure.DemeureService.record(p,new net.minecraft.core.BlockPos(152,-40,136),.01);});
c.waitTicks(45);c.runOnClient(m->require(!button(m.gui.screen(),"sanctuary.claims.claim_at_cost").active,"A passing trace never enables the claim"));
world.getServer().runOnServer(server->{var p=server.getPlayerList().getPlayers().getFirst();fr.koka.demeure.DemeureService.record(p,new net.minecraft.core.BlockPos(152,-40,136),.5);});
c.waitFor(m->residency(m.gui.screen())!=null&&residency(m.gui.screen()).reason().equals("habitation_ready")&&button(m.gui.screen(),"sanctuary.claims.claim_at_cost").active,200);
world.getServer().runOnServer(server->{var guest=guest(server);neighbour.set(guest);fr.koka.demeure.DemeureService.record(guest,new net.minecraft.core.BlockPos(152,-40,136),.6);});
c.waitFor(m->residency(m.gui.screen())!=null&&residency(m.gui.screen()).reason().equals("habitation_priority"),200);
c.runOnClient(m->{require(residency(m.gui.screen()).name().equals("Nora")&&!button(m.gui.screen(),"sanctuary.claims.claim_at_cost").active,"A later stronger inhabitant is named and prevents the first resident from claiming");});
c.waitTicks(3);c.takeScreenshot("claims277-stronger-neighbour");
world.getServer().runOnServer(server->{var p=server.getPlayerList().getPlayers().getFirst();fr.koka.demeure.DemeureService.record(p,new net.minecraft.core.BlockPos(152,-40,136),.2);});
c.waitFor(m->residency(m.gui.screen())!=null&&residency(m.gui.screen()).reason().equals("habitation_ready")&&button(m.gui.screen(),"sanctuary.claims.claim_at_cost").active,200);
c.waitTicks(3);c.takeScreenshot("claims277-priority-regained");
c.waitTicks(3);c.takeScreenshot("claims277-map-free");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.claims.claim_at_cost").onPress(ENTER));
c.waitFor(m->view(m.gui.screen())!=null&&view(m.gui.screen()).count()==1,200);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();require(map.claimOverlay.get(9,8)!=null&&map.claimOverlay.remaining(map.claimOverlay.get(9,8))==0,"New ownership appears immediately without reopening the map");});
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().setItem(9,new net.minecraft.world.item.ItemStack(fr.koka.sanctuary.gems.Gems.SAPPHIRE.gem(),8));p.inventoryMenu.broadcastChanges();});
c.waitTicks(5);c.runOnClient(m->{m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));rightClick(m.gui.screen());});
c.waitFor(m->view(m.gui.screen())!=null&&view(m.gui.screen()).sapphires()==8,200);
c.waitTicks(3);c.takeScreenshot("claims277-map-sapphires");
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();int border=0xFF000000|map.claimOverlay.get(9,8).claim().color();require(pixel(map,144,132)==border&&pixel(map,152,136)==0,"Unprotected claim is visible as an outline with a clear interior");require(button(map,"sanctuary.claims.protect_at_cost").getMessage().getString().contains("8"),"Context action displays the new price");});
c.runOnClient(m->button(m.gui.screen(),"sanctuary.claims.protect_at_cost").onPress(ENTER));
c.waitFor(m->view(m.gui.screen())!=null&&view(m.gui.screen()).message().equals("protected"),200);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();require(map.claimOverlay.remaining(map.claimOverlay.get(9,8))>ChunkClaims.PROTECTION_MILLIS-10_000,"Protection updates the currently open map immediately");require(widgets(map).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->key(w).equals("sanctuary.claims.protected")).allMatch(w->w.getMessage().getString().contains("three")||w.getMessage().getString().contains("trois")),"Purchase confirmation uses the new duration");});
world.getServer().runOnServer(server->{var p=server.getPlayerList().getPlayers().getFirst();p.level().getChunk(10,8);var store=ChunkPropertyService.store(server);fr.koka.demeure.DemeureService.record(p,new net.minecraft.core.BlockPos(160,-40,128),1);require(ChunkPropertyService.act(p,"claim",new ChunkClaims.Key(10,8),store.revision()).equals("claimed"),"Create an adjacent unprotected claim while the map stays open");});
c.waitFor(m->((SanctuaryAtlasScreen)m.gui.screen()).claimOverlay.get(10,8)!=null,200);c.waitTicks(3);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();int fill=0x40000000|map.claimOverlay.get(9,8).claim().color();int border=0xFF000000|map.claimOverlay.get(9,8).claim().color();require(pixel(map,159,132)==fill&&pixel(map,160,132)==0&&pixel(map,175,132)==border,"Live neighbour arrival removes both shared contours across protection states");});
world.getServer().runOnServer(server->{var p=server.getPlayerList().getPlayers().getFirst();p.getInventory().setItem(9,new net.minecraft.world.item.ItemStack(fr.koka.sanctuary.gems.Gems.SAPPHIRE.gem(),8));var store=ChunkPropertyService.store(server);require(ChunkPropertyService.act(p,"protect",new ChunkClaims.Key(10,8),store.revision()).equals("protected"),"Protect the second chunk for eight sapphires");});
c.waitFor(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();return map.claimOverlay.remaining(map.claimOverlay.get(10,8))>0;},200);c.waitTicks(3);
world.getServer().runOnServer(server->{var p=server.getPlayerList().getPlayers().getFirst();var guest=neighbour.get();guest.level().getChunk(12,8);fr.koka.demeure.DemeureService.record(p,new net.minecraft.core.BlockPos(192,-40,128),.5);fr.koka.demeure.DemeureService.record(guest,new net.minecraft.core.BlockPos(192,-40,128),.6);var store=ChunkPropertyService.store(server);require(ChunkPropertyService.act(guest,"claim",new ChunkClaims.Key(12,8),store.revision()).equals("claimed"),"Later stronger inhabitant really claims an unclaimed spot");require(store.get(new ChunkClaims.Key(9,8)).owner().equals(p.getUUID()),"Increased neighbour influence never transfers the existing purchased claim");fr.koka.demeure.DemeureService.record(p,new net.minecraft.core.BlockPos(136,-40,152),.5);fr.koka.demeure.DemeureService.record(guest,new net.minecraft.core.BlockPos(136,-40,152),.6);});
world.getServer().runOnServer(server->{
var p=server.getPlayerList().getPlayers().getFirst();var identities=ProgressionService.session(server).inhabitants;
UUID offline=UUID.randomUUID();int color=identities.available().getFirst();
try{require(identities.create(offline,"Camille","",color),"Offline owner identity created");identities.arrive(offline);var store=ChunkPropertyService.store(server);
fixture(store,p.getUUID(),11,8,false);fixture(store,p.getUUID(),11,9,false);fixture(store,offline,9,9,true);fixture(store,offline,10,9,true);
}catch(Exception e){throw new RuntimeException(e);}
});
c.runOnClient(m->{m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));button(m.gui.screen(),"sanctuary.atlas.recenter").onPress(ENTER);});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.claimOverlay.get(10,9)!=null&&map.claimOverlay.get(11,8)!=null,400);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();var own=map.claimOverlay.get(9,8);var other=map.claimOverlay.get(10,9);
require(own!=null&&own.claim().color()!=other.claim().color(),"Two owners have their distinct persistent colors before any context click");
require(map.claimOverlay.remaining(own)>0&&map.claimOverlay.remaining(map.claimOverlay.get(11,8))==0,"Same owner has both protected and unprotected chunks");
require(other.claim().name().equals("Camille")&&map.claimOverlay.remaining(other)>0,"Offline owner name, color and active protection arrive with map tiles");
});
c.waitTicks(3);c.takeScreenshot("claims277-local-two-owners");
world.getServer().runOnServer(server->{var p=server.getPlayerList().getPlayers().getFirst();require(ChunkPropertyService.sapphires(p)==0,"Both protections debit exactly eight sapphires");});
c.waitFor(m->ProgressionClient.progress.atlas(),200);c.waitTicks(30);
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 SanctuaryAtlasScreen(null,new fr.koka.sanctuary.community.CommunityTask.Location("minecraft:overworld",152,-40,160)));});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.renderedPixelAt(152,136)!=0,400);
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.claimOverlay.get(10,9)!=null&&map.claimOverlay.get(10,8)!=null,400);
c.waitTicks(3);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();int own=0x40000000|map.claimOverlay.get(9,8).claim().color();int other=0x40000000|map.claimOverlay.get(10,9).claim().color();
int ownBorder=0xFF000000|map.claimOverlay.get(9,8).claim().color(),otherBorder=0xFF000000|map.claimOverlay.get(10,9).claim().color();
require(pixel(map,144,128)==ownBorder&&pixel(map,159,132)==own&&pixel(map,160,132)==own,"Protected neighbours retain one outer contour and continuous fill");
require(pixel(map,144,144)==otherBorder&&pixel(map,159,148)==other&&pixel(map,160,148)==other,"Offline owner's adjacent chunks connect without an internal contour");
require(pixel(map,183,136)==0&&pixel(map,191,136)==ownBorder&&pixel(map,176,132)==0,"Unprotected claims show their outer contour without a fill or shared-edge stroke");
require(pixel(map,168,143)==ownBorder&&pixel(map,168,144)==otherBorder,"Different owners retain their distinct boundaries");
});
c.takeScreenshot("claims277-colors-"+locale+"-gui"+scale);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();var guest=neighbour.get();map.acceptResidency(new ChunkPropertyPayloads.Residency(1000,1000,"habitation_priority","Wrong tile",0xFF0000));require(map.propertyView()==null,"A residency reply for another tile cannot open a panel");});
c.runOnClient(m->rightClick(m.gui.screen()));
c.waitFor(m->view(m.gui.screen())!=null,200);
c.runOnClient(m->{var d=view(m.gui.screen());require(d.x()==9&&d.z()==8,"Ordinary Atlas right-click selects actual chunk without a mode toggle");widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.visible).forEach(b->require(b.getX()>=0&&b.getRight()<=m.gui.screen().width&&b.getBottom()<=m.gui.screen().height,"Visible controls fit"));});
c.waitTicks(3);c.takeScreenshot("claims277-map-protected-"+locale+"-gui"+scale);
c.runOnClient(m->{m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));require(m.gui.screen() instanceof SanctuaryAtlasScreen&&view(m.gui.screen())==null,"Escape closes the context panel and keeps the map");});
c.runOnClient(m->rightClickAt(m.gui.screen(),136,152));
c.waitFor(m->residency(m.gui.screen())!=null&&residency(m.gui.screen()).reason().equals("habitation_priority"),200);
c.runOnClient(m->{require(residency(m.gui.screen()).name().equals("Nora")&&!button(m.gui.screen(),"sanctuary.claims.claim_at_cost").active,"Priority details and disabled action render in each locale and size");});
c.waitTicks(3);c.takeScreenshot("claims277-priority-"+locale+"-gui"+scale);
c.runOnClient(m->m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0)));
}
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.claims.map_visible").onPress(ENTER);require(!ChunkPropertyClient.propertiesVisible(),"Properties can be hidden without changing server protection");rightClick(m.gui.screen());});
c.waitFor(m->view(m.gui.screen())!=null,200);
c.runOnClient(m->{require(view(m.gui.screen()).x()==9,"Right-click remains available when claims are hidden");m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));});
c.waitTicks(3);c.takeScreenshot("claims277-hidden-map");
c.runOnClient(m->m.gui.setScreen(new net.minecraft.client.gui.screens.PauseScreen(true)));
c.waitFor(m->m.gui.screen() instanceof PauseAtlasAccess pause&&pause.sanctuary$pauseMap()!=null&&pause.sanctuary$pauseMap().claimOverlay.get(10,9)!=null,400);
c.runOnClient(m->require(!ChunkPropertyClient.propertiesVisible(),"Hidden state is shared with Pause and retained when the map closes"));
c.waitTicks(3);c.takeScreenshot("claims277-hidden-pause");
c.runOnClient(m->((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap().onPress(ENTER));
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.claimOverlay.get(10,9)!=null&&map.claimOverlay.get(11,8)!=null,400);
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.claims.map_hidden").onPress(ENTER);require(ChunkPropertyClient.propertiesVisible(),"Properties can be shown again with their cached current state");});
c.waitTicks(3);c.takeScreenshot("claims277-restored-map");
c.runOnClient(m->m.gui.setScreen(new net.minecraft.client.gui.screens.PauseScreen(true)));
c.waitFor(m->m.gui.screen() instanceof PauseAtlasAccess pause&&pause.sanctuary$pauseMap()!=null&&pause.sanctuary$pauseMap().claimOverlay.get(10,9)!=null,400);
c.runOnClient(m->{var map=((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap();require(map.claimOverlay.get(10,9).claim().name().equals("Camille"),"Pause preview receives offline ownership with its terrain tiles");var pixels=((net.minecraft.client.renderer.texture.DynamicTexture)field(map,"texture")).getPixels();int ox=(int)field(map,"originX"),oz=(int)field(map,"originZ");int border=0xFF000000|map.claimOverlay.get(11,8).claim().color();require(pixels.getPixel(191-ox,136-oz)==border,"Pause uses the same pixel contour for an unprotected claim");});
c.waitTicks(3);c.takeScreenshot("claims277-pause-preview");
c.runOnClient(m->m.gui.setScreen(null));
}finally{Starter039TestSupport.manual=false;}
}
private static net.minecraft.server.level.ServerPlayer guest(net.minecraft.server.MinecraftServer server){
var profile=new com.mojang.authlib.GameProfile(UUID.randomUUID(),"Nora");
var player=new net.minecraft.server.level.ServerPlayer(server,server.overworld(),profile,net.minecraft.server.level.ClientInformation.createDefault());
var connection=new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);new io.netty.channel.embedded.EmbeddedChannel(connection);
player.connection=new net.minecraft.server.network.ServerGamePacketListenerImpl(server,connection,player,net.minecraft.server.network.CommonListenerCookie.createInitial(profile,false));
player.setGameMode(net.minecraft.world.level.GameType.SURVIVAL);player.setPos(192,-40,128);
var identities=ProgressionService.session(server).inhabitants;
try{require(identities.create(player.getUUID(),"Nora","",identities.available().getFirst()),"Create neighbour identity");identities.arrive(player.getUUID());}catch(Exception e){throw new RuntimeException(e);}
player.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(player,ProgressRecord.INITIAL);player.setExperienceLevels(20);
require(ProgressionService.purchase(player,"atlas",ProgressEffects.get(player).revision()),"Neighbour has a purchased Atlas");return player;
}
private static ChunkPropertyPayloads.Residency residency(Screen s){return s instanceof SanctuaryAtlasScreen map?(ChunkPropertyPayloads.Residency)field(map,"propertyResidency"):null;}
private static void fixture(ChunkClaims store,UUID owner,int x,int z,boolean protect)throws Exception{
var key=new ChunkClaims.Key(x,z);var tx=store.prepare(owner,"claim",key,store.revision());require(tx!=null,"Fixture chunk is free");store.recover(owner,tx.receipt().toString());
if(protect){tx=store.prepare(owner,"protect",key,store.revision());store.recover(owner,tx.receipt().toString());}
}
private static void checkCache(){
var time=new java.util.concurrent.atomic.AtomicLong();var cache=new ClaimMapOverlay(time::get);UUID id=UUID.randomUUID();var key=new ChunkClaims.Key(-1,2);
var paint=new ChunkPropertyPayloads.Paint(-1,2,id,"Test",0x123456,1000);cache.begin(7);cache.observe(-1,2);cache.accept(new ChunkPropertyPayloads.Overlay(7,List.of(key),List.of(paint)));
require(cache.get(-1,2).claim().color()==0x123456&&cache.remaining(cache.get(-1,2))==1000,"Exact RGB is retained");
time.set(1_001_000_000L);require(cache.remaining(cache.get(-1,2))==0&&cache.get(-1,2)!=null,"Expiration changes style while keeping ownership");require(cache.ink(-1,2).pixel(0,8)==0xFF123456&&cache.ink(-1,2).pixel(8,8)==0&&cache.expiredTiles().contains(key)&&cache.expiredTiles().isEmpty(),"Expiry removes the fill, retains the outline and invalidates once");
cache.begin(8);cache.accept(new ChunkPropertyPayloads.Overlay(7,List.of(key),List.of()));require(cache.get(-1,2)!=null,"Stale viewport cannot erase current claims");
cache.accept(new ChunkPropertyPayloads.Overlay(0,List.of(key),List.of(paint)));require(cache.remaining(cache.get(-1,2))==1000,"Live purchase update survives a pending viewport refresh");
var hidden=new ChunkClaims.Key(30,30);cache.accept(new ChunkPropertyPayloads.Overlay(0,List.of(hidden),List.of(new ChunkPropertyPayloads.Paint(30,30,id,"Hidden",0xFFFFFF,1000))));require(cache.get(30,30)==null,"Live updates cannot reveal an unobserved chunk");
cache.accept(new ChunkPropertyPayloads.Overlay(8,List.of(key),List.of()));require(cache.get(-1,2)==null,"An empty current tile removes stale paint");cache.clear();
var a=new ChunkClaims.Key(0,0);var east=new ChunkClaims.Key(1,0);var south=new ChunkClaims.Key(0,1);var diagonal=new ChunkClaims.Key(1,1);
cache.begin(9);for(var k:List.of(a,east,south,diagonal))cache.observe(k.x(),k.z());
cache.accept(new ChunkPropertyPayloads.Overlay(9,List.of(a,east,south),List.of(new ChunkPropertyPayloads.Paint(0,0,id,"Test",0x123456,0),new ChunkPropertyPayloads.Paint(1,0,id,"Test",0x123456,1000),new ChunkPropertyPayloads.Paint(0,1,id,"Test",0x123456,0))));
require(cache.ink(0,0).pixel(15,8)==0&&cache.ink(0,0).pixel(8,15)==0&&cache.ink(0,0).pixel(15,15)==0xFF123456,"L-shaped property removes internal edges and closes its concave corner");
require(cache.ink(1,0).pixel(0,8)==0x40123456,"Protected fill joins unprotected ownership without a border");
cache.accept(new ChunkPropertyPayloads.Overlay(0,List.of(diagonal),List.of(new ChunkPropertyPayloads.Paint(1,1,id,"Test",0x123456,0))));
require(cache.ink(0,0).pixel(15,15)==0,"Completing a square removes the former concave corner");
cache.accept(new ChunkPropertyPayloads.Overlay(0,List.of(diagonal),List.of()));
require(cache.ink(0,0).pixel(15,15)==0xFF123456,"Removal restores the adjoining corner");
require(ClaimMapOverlay.affected(List.of(diagonal)).contains(a),"Diagonal invalidation includes the affected corner tile");
require(cache.ink(1_875_000,1_875_000).pixel(0,0)==0&&cache.get(1_875_001,0)==null,"World-edge adjacency is bounded");
}
private static int pixel(SanctuaryAtlasScreen map,int x,int z){return ((net.minecraft.client.renderer.texture.DynamicTexture)field(map,"footprintTexture")).getPixels().getPixel(x-(int)field(map,"originX"),z-(int)field(map,"originZ"));}
private static Object field(Object o,String name){try{var f=o.getClass().getDeclaredField(name);f.setAccessible(true);return f.get(o);}catch(Exception e){throw new AssertionError(e);}}
private static ChunkPropertyPayloads.View view(Screen screen){return screen instanceof SanctuaryAtlasScreen map?map.propertyView():null;}
private static void rightClick(Screen screen){rightClickAt(screen,152,136);}
private static void rightClickAt(Screen screen,int x,int z){var atlas=(SanctuaryAtlasScreen)screen;require(atlas.mouseClicked(new MouseButtonEvent(atlas.width/2.+(x-atlas.centerX())*atlas.zoom(),(atlas.contentTop+atlas.contentBottom)/2.+(z-atlas.centerZ())*atlas.zoom(),new MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_RIGHT,0)),false),"Right click is handled on the terrain");}
private static String key(AbstractWidget w){return w.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
private static java.util.stream.Stream<GuiEventListener> widgets(ContainerEventHandler c){return c.children().stream().flatMap(w->w instanceof ContainerEventHandler nested?java.util.stream.Stream.concat(java.util.stream.Stream.of(w),widgets(nested)):java.util.stream.Stream.of(w));}
private static Button button(Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals(key)).findFirst().orElseThrow(()->new AssertionError("Missing button "+key));}
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,116 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.property.*;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.network.chat.contents.TranslatableContents;
public final class ClaimMap273ClientChecks implements FabricClientGameTest {
private static final KeyEvent ENTER=new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);
public void runTest(ClientGameTestContext c){
Starter039TestSupport.manual=true;var onboarding=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen s){
Starter039TestSupport.selectFirst(s);s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(ENTER);
button(s,"sanctuary.progression.enter").onPress(ENTER);onboarding.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("ClaimMap273 "+UUID.randomUUID());s.setSeed("273");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(128,-40,128);p.setNoGravity(true);s.setDifficulty(net.minecraft.world.Difficulty.PEACEFUL,true);});
checkCache();
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.getConnection().sendCommand("sanctuary property");});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.renderedPixelAt(152,136)!=0,400);
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.atlas.recenter").onPress(ENTER);button(m.gui.screen(),"sanctuary.atlas.recenter").onPress(ENTER);});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.isComplete()&&map.status().equals("ready"),400);
c.runOnClient(m->{require(!ProgressionClient.progress.atlas(),"Property map does not grant the Atlas aptitude");rightClick(m.gui.screen());});
c.waitFor(m->view(m.gui.screen())!=null,200);
c.runOnClient(m->{var d=view(m.gui.screen());require(d.eligible()&&d.count()==0&&d.cost()==0,"Hello World admits free chunk selection");});
c.waitTicks(3);c.takeScreenshot("claims273-map-free");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.claims.claim_at_cost").onPress(ENTER));
c.waitFor(m->view(m.gui.screen())!=null&&view(m.gui.screen()).count()==1,200);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();require(map.claimOverlay.get(9,8)!=null&&map.claimOverlay.remaining(map.claimOverlay.get(9,8))==0,"New ownership appears immediately without reopening the map");});
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().setItem(9,new net.minecraft.world.item.ItemStack(fr.koka.sanctuary.gems.Gems.SAPPHIRE.gem(),64));p.inventoryMenu.broadcastChanges();});
c.waitTicks(5);c.runOnClient(m->{m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));rightClick(m.gui.screen());});
c.waitFor(m->view(m.gui.screen())!=null&&view(m.gui.screen()).sapphires()==64,200);
c.waitTicks(3);c.takeScreenshot("claims273-map-sapphires");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.claims.protect_at_cost").onPress(ENTER));
c.waitFor(m->view(m.gui.screen())!=null&&view(m.gui.screen()).message().equals("protected"),200);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();require(map.claimOverlay.remaining(map.claimOverlay.get(9,8))>ChunkClaims.PROTECTION_MILLIS-10_000,"Protection updates the currently open map immediately");});
world.getServer().runOnServer(server->{
var p=server.getPlayerList().getPlayers().getFirst();var identities=ProgressionService.session(server).inhabitants;
UUID offline=UUID.randomUUID();int color=identities.available().getFirst();
try{require(identities.create(offline,"Camille","",color),"Offline owner identity created");identities.arrive(offline);var store=ChunkPropertyService.store(server);
fixture(store,p.getUUID(),10,8,false);fixture(store,offline,9,9,false);fixture(store,offline,10,9,true);
}catch(Exception e){throw new RuntimeException(e);}
});
c.runOnClient(m->{m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));button(m.gui.screen(),"sanctuary.atlas.recenter").onPress(ENTER);});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.claimOverlay.get(10,9)!=null,400);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();var own=map.claimOverlay.get(9,8);var other=map.claimOverlay.get(10,9);
require(own!=null&&own.claim().color()!=other.claim().color(),"Two owners have their distinct persistent colors before any context click");
require(map.claimOverlay.remaining(own)>0&&map.claimOverlay.remaining(map.claimOverlay.get(10,8))==0,"Same owner has both protected and unprotected chunks");
require(other.claim().name().equals("Camille")&&map.claimOverlay.remaining(other)>0,"Offline owner name, color and active protection arrive with map tiles");
});
c.waitTicks(3);c.takeScreenshot("claims273-local-two-owners");
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();require(ChunkPropertyService.sapphires(p)==0,"Map action debits full stack");p.setExperienceLevels(100);require(ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()),"Purchase Atlas normally");ProgressionService.send(p,"");});
c.waitFor(m->ProgressionClient.progress.atlas(),200);c.waitTicks(30);
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 SanctuaryAtlasScreen(null,new fr.koka.sanctuary.community.CommunityTask.Location("minecraft:overworld",152,-40,160)));});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.renderedPixelAt(152,136)!=0,400);
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.claimOverlay.get(10,9)!=null&&map.claimOverlay.get(10,8)!=null,400);
c.waitTicks(3);c.takeScreenshot("claims273-colors-"+locale+"-gui"+scale);
c.runOnClient(m->rightClick(m.gui.screen()));
c.waitFor(m->view(m.gui.screen())!=null,200);
c.runOnClient(m->{var d=view(m.gui.screen());require(d.x()==9&&d.z()==8,"Ordinary Atlas right-click selects actual chunk without a mode toggle");widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.visible).forEach(b->require(b.getX()>=0&&b.getRight()<=m.gui.screen().width&&b.getBottom()<=m.gui.screen().height,"Visible controls fit"));});
c.waitTicks(3);c.takeScreenshot("claims273-map-protected-"+locale+"-gui"+scale);
c.runOnClient(m->{m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));require(m.gui.screen() instanceof SanctuaryAtlasScreen&&view(m.gui.screen())==null,"Escape closes the context panel and keeps the map");});
}
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.claims.map_visible").onPress(ENTER);require(!ChunkPropertyClient.propertiesVisible(),"Properties can be hidden without changing server protection");rightClick(m.gui.screen());});
c.waitFor(m->view(m.gui.screen())!=null,200);
c.runOnClient(m->{require(view(m.gui.screen()).x()==9,"Right-click remains available when claims are hidden");m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));});
c.waitTicks(3);c.takeScreenshot("claims273-hidden-map");
c.runOnClient(m->m.gui.setScreen(new net.minecraft.client.gui.screens.PauseScreen(true)));
c.waitFor(m->m.gui.screen() instanceof PauseAtlasAccess pause&&pause.sanctuary$pauseMap()!=null&&pause.sanctuary$pauseMap().claimOverlay.get(10,9)!=null,400);
c.runOnClient(m->require(!ChunkPropertyClient.propertiesVisible(),"Hidden state is shared with Pause and retained when the map closes"));
c.waitTicks(3);c.takeScreenshot("claims273-hidden-pause");
c.runOnClient(m->((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap().onPress(ENTER));
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.claimOverlay.get(10,9)!=null,400);
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.claims.map_hidden").onPress(ENTER);require(ChunkPropertyClient.propertiesVisible(),"Properties can be shown again with their cached current state");});
c.waitTicks(3);c.takeScreenshot("claims273-restored-map");
c.runOnClient(m->m.gui.setScreen(new net.minecraft.client.gui.screens.PauseScreen(true)));
c.waitFor(m->m.gui.screen() instanceof PauseAtlasAccess pause&&pause.sanctuary$pauseMap()!=null&&pause.sanctuary$pauseMap().claimOverlay.get(10,9)!=null,400);
c.runOnClient(m->{var map=((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap();require(map.claimOverlay.get(10,9).claim().name().equals("Camille"),"Pause preview receives offline ownership with its terrain tiles");});
c.waitTicks(3);c.takeScreenshot("claims273-pause-preview");
c.runOnClient(m->m.gui.setScreen(null));
}finally{Starter039TestSupport.manual=false;}
}
private static void fixture(ChunkClaims store,UUID owner,int x,int z,boolean protect)throws Exception{
var key=new ChunkClaims.Key(x,z);var tx=store.prepare(owner,"claim",key,store.revision());require(tx!=null,"Fixture chunk is free");store.recover(owner,tx.receipt().toString());
if(protect){tx=store.prepare(owner,"protect",key,store.revision());store.recover(owner,tx.receipt().toString());}
}
private static void checkCache(){
var time=new java.util.concurrent.atomic.AtomicLong();var cache=new ClaimMapOverlay(time::get);UUID id=UUID.randomUUID();var key=new ChunkClaims.Key(-1,2);
var paint=new ChunkPropertyPayloads.Paint(-1,2,id,"Test",0x123456,1000);cache.begin(7);cache.observe(-1,2);cache.accept(new ChunkPropertyPayloads.Overlay(7,List.of(key),List.of(paint)));
require(cache.get(-1,2).claim().color()==0x123456&&cache.remaining(cache.get(-1,2))==1000,"Exact RGB is retained");
time.set(1_001_000_000L);require(cache.remaining(cache.get(-1,2))==0&&cache.get(-1,2)!=null,"Expiration changes style while keeping ownership");
cache.begin(8);cache.accept(new ChunkPropertyPayloads.Overlay(7,List.of(key),List.of()));require(cache.get(-1,2)!=null,"Stale viewport cannot erase current claims");
cache.accept(new ChunkPropertyPayloads.Overlay(0,List.of(key),List.of(paint)));require(cache.remaining(cache.get(-1,2))==1000,"Live purchase update survives a pending viewport refresh");
var hidden=new ChunkClaims.Key(30,30);cache.accept(new ChunkPropertyPayloads.Overlay(0,List.of(hidden),List.of(new ChunkPropertyPayloads.Paint(30,30,id,"Hidden",0xFFFFFF,1000))));require(cache.get(30,30)==null,"Live updates cannot reveal an unobserved chunk");
cache.accept(new ChunkPropertyPayloads.Overlay(8,List.of(key),List.of()));require(cache.get(-1,2)==null,"An empty current tile removes stale paint");cache.clear();
}
private static ChunkPropertyPayloads.View view(Screen screen){return screen instanceof SanctuaryAtlasScreen map?map.propertyView():null;}
private static void rightClick(Screen screen){var atlas=(SanctuaryAtlasScreen)screen;require(atlas.mouseClicked(new MouseButtonEvent(atlas.width/2.+(152-atlas.centerX())*atlas.zoom(),(atlas.contentTop+atlas.contentBottom)/2.+(136-atlas.centerZ())*atlas.zoom(),new MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_RIGHT,0)),false),"Right click is handled on the terrain");}
private static String key(AbstractWidget w){return w.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
private static java.util.stream.Stream<GuiEventListener> widgets(ContainerEventHandler c){return c.children().stream().flatMap(w->w instanceof ContainerEventHandler nested?java.util.stream.Stream.concat(java.util.stream.Stream.of(w),widgets(nested)):java.util.stream.Stream.of(w));}
private static Button button(Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals(key)).findFirst().orElseThrow(()->new AssertionError("Missing button "+key));}
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,145 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.property.*;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.network.chat.contents.TranslatableContents;
public final class ClaimMap276ClientChecks implements FabricClientGameTest {
private static final KeyEvent ENTER=new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);
public void runTest(ClientGameTestContext c){
Starter039TestSupport.manual=true;var onboarding=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen s){
Starter039TestSupport.selectFirst(s);s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(ENTER);
button(s,"sanctuary.progression.enter").onPress(ENTER);onboarding.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("ClaimMap276 "+UUID.randomUUID());s.setSeed("276");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(128,-40,128);p.setNoGravity(true);s.setDifficulty(net.minecraft.world.Difficulty.PEACEFUL,true);});
world.getServer().runOnServer(server->{var p=server.getPlayerList().getPlayers().getFirst();p.setExperienceLevels(20);require(ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()),"Purchase actual Atlas before accessing properties");ProgressionService.send(p,"");});
c.waitFor(m->ProgressionClient.progress.atlas(),200);
checkCache();
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.getConnection().sendCommand("sanctuary property");});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.renderedPixelAt(152,136)!=0,400);
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.atlas.recenter").onPress(ENTER);button(m.gui.screen(),"sanctuary.atlas.recenter").onPress(ENTER);});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.isComplete()&&map.status().equals("ready"),400);
c.runOnClient(m->{require(ProgressionClient.progress.atlas(),"Property management requires the purchased Atlas");rightClick(m.gui.screen());});
c.waitFor(m->view(m.gui.screen())!=null,200);
c.runOnClient(m->{var d=view(m.gui.screen());require(d.eligible()&&d.count()==0&&d.cost()==0,"Hello World admits free chunk selection");});
c.waitTicks(3);c.takeScreenshot("claims276-map-free");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.claims.claim_at_cost").onPress(ENTER));
c.waitFor(m->view(m.gui.screen())!=null&&view(m.gui.screen()).count()==1,200);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();require(map.claimOverlay.get(9,8)!=null&&map.claimOverlay.remaining(map.claimOverlay.get(9,8))==0,"New ownership appears immediately without reopening the map");});
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().setItem(9,new net.minecraft.world.item.ItemStack(fr.koka.sanctuary.gems.Gems.SAPPHIRE.gem(),8));p.inventoryMenu.broadcastChanges();});
c.waitTicks(5);c.runOnClient(m->{m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));rightClick(m.gui.screen());});
c.waitFor(m->view(m.gui.screen())!=null&&view(m.gui.screen()).sapphires()==8,200);
c.waitTicks(3);c.takeScreenshot("claims276-map-sapphires");
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();int border=0xFF000000|map.claimOverlay.get(9,8).claim().color();require(pixel(map,144,132)==border&&pixel(map,152,136)==0,"Unprotected claim is visible as an outline with a clear interior");require(button(map,"sanctuary.claims.protect_at_cost").getMessage().getString().contains("8"),"Context action displays the new price");});
c.runOnClient(m->button(m.gui.screen(),"sanctuary.claims.protect_at_cost").onPress(ENTER));
c.waitFor(m->view(m.gui.screen())!=null&&view(m.gui.screen()).message().equals("protected"),200);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();require(map.claimOverlay.remaining(map.claimOverlay.get(9,8))>ChunkClaims.PROTECTION_MILLIS-10_000,"Protection updates the currently open map immediately");require(widgets(map).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->key(w).equals("sanctuary.claims.protected")).allMatch(w->w.getMessage().getString().contains("three")||w.getMessage().getString().contains("trois")),"Purchase confirmation uses the new duration");});
world.getServer().runOnServer(server->{var p=server.getPlayerList().getPlayers().getFirst();p.level().getChunk(10,8);var store=ChunkPropertyService.store(server);require(ChunkPropertyService.act(p,"claim",new ChunkClaims.Key(10,8),store.revision()).equals("claimed"),"Create an adjacent unprotected claim while the map stays open");});
c.waitFor(m->((SanctuaryAtlasScreen)m.gui.screen()).claimOverlay.get(10,8)!=null,200);c.waitTicks(3);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();int fill=0x40000000|map.claimOverlay.get(9,8).claim().color();int border=0xFF000000|map.claimOverlay.get(9,8).claim().color();require(pixel(map,159,132)==fill&&pixel(map,160,132)==0&&pixel(map,175,132)==border,"Live neighbour arrival removes both shared contours across protection states");});
world.getServer().runOnServer(server->{var p=server.getPlayerList().getPlayers().getFirst();p.getInventory().setItem(9,new net.minecraft.world.item.ItemStack(fr.koka.sanctuary.gems.Gems.SAPPHIRE.gem(),8));var store=ChunkPropertyService.store(server);require(ChunkPropertyService.act(p,"protect",new ChunkClaims.Key(10,8),store.revision()).equals("protected"),"Protect the second chunk for eight sapphires");});
c.waitFor(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();return map.claimOverlay.remaining(map.claimOverlay.get(10,8))>0;},200);c.waitTicks(3);
world.getServer().runOnServer(server->{
var p=server.getPlayerList().getPlayers().getFirst();var identities=ProgressionService.session(server).inhabitants;
UUID offline=UUID.randomUUID();int color=identities.available().getFirst();
try{require(identities.create(offline,"Camille","",color),"Offline owner identity created");identities.arrive(offline);var store=ChunkPropertyService.store(server);
fixture(store,p.getUUID(),11,8,false);fixture(store,p.getUUID(),11,9,false);fixture(store,offline,9,9,true);fixture(store,offline,10,9,true);
}catch(Exception e){throw new RuntimeException(e);}
});
c.runOnClient(m->{m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));button(m.gui.screen(),"sanctuary.atlas.recenter").onPress(ENTER);});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.claimOverlay.get(10,9)!=null&&map.claimOverlay.get(11,8)!=null,400);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();var own=map.claimOverlay.get(9,8);var other=map.claimOverlay.get(10,9);
require(own!=null&&own.claim().color()!=other.claim().color(),"Two owners have their distinct persistent colors before any context click");
require(map.claimOverlay.remaining(own)>0&&map.claimOverlay.remaining(map.claimOverlay.get(11,8))==0,"Same owner has both protected and unprotected chunks");
require(other.claim().name().equals("Camille")&&map.claimOverlay.remaining(other)>0,"Offline owner name, color and active protection arrive with map tiles");
});
c.waitTicks(3);c.takeScreenshot("claims276-local-two-owners");
world.getServer().runOnServer(server->{var p=server.getPlayerList().getPlayers().getFirst();require(ChunkPropertyService.sapphires(p)==0,"Both protections debit exactly eight sapphires");});
c.waitFor(m->ProgressionClient.progress.atlas(),200);c.waitTicks(30);
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 SanctuaryAtlasScreen(null,new fr.koka.sanctuary.community.CommunityTask.Location("minecraft:overworld",152,-40,160)));});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.renderedPixelAt(152,136)!=0,400);
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.claimOverlay.get(10,9)!=null&&map.claimOverlay.get(10,8)!=null,400);
c.waitTicks(3);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();int own=0x40000000|map.claimOverlay.get(9,8).claim().color();int other=0x40000000|map.claimOverlay.get(10,9).claim().color();
int ownBorder=0xFF000000|map.claimOverlay.get(9,8).claim().color(),otherBorder=0xFF000000|map.claimOverlay.get(10,9).claim().color();
require(pixel(map,144,128)==ownBorder&&pixel(map,159,132)==own&&pixel(map,160,132)==own,"Protected neighbours retain one outer contour and continuous fill");
require(pixel(map,144,144)==otherBorder&&pixel(map,159,148)==other&&pixel(map,160,148)==other,"Offline owner's adjacent chunks connect without an internal contour");
require(pixel(map,183,136)==0&&pixel(map,191,136)==ownBorder&&pixel(map,176,132)==0,"Unprotected claims show their outer contour without a fill or shared-edge stroke");
require(pixel(map,168,143)==ownBorder&&pixel(map,168,144)==otherBorder,"Different owners retain their distinct boundaries");
});
c.takeScreenshot("claims276-colors-"+locale+"-gui"+scale);
c.runOnClient(m->rightClick(m.gui.screen()));
c.waitFor(m->view(m.gui.screen())!=null,200);
c.runOnClient(m->{var d=view(m.gui.screen());require(d.x()==9&&d.z()==8,"Ordinary Atlas right-click selects actual chunk without a mode toggle");widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.visible).forEach(b->require(b.getX()>=0&&b.getRight()<=m.gui.screen().width&&b.getBottom()<=m.gui.screen().height,"Visible controls fit"));});
c.waitTicks(3);c.takeScreenshot("claims276-map-protected-"+locale+"-gui"+scale);
c.runOnClient(m->{m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));require(m.gui.screen() instanceof SanctuaryAtlasScreen&&view(m.gui.screen())==null,"Escape closes the context panel and keeps the map");});
}
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.claims.map_visible").onPress(ENTER);require(!ChunkPropertyClient.propertiesVisible(),"Properties can be hidden without changing server protection");rightClick(m.gui.screen());});
c.waitFor(m->view(m.gui.screen())!=null,200);
c.runOnClient(m->{require(view(m.gui.screen()).x()==9,"Right-click remains available when claims are hidden");m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));});
c.waitTicks(3);c.takeScreenshot("claims276-hidden-map");
c.runOnClient(m->m.gui.setScreen(new net.minecraft.client.gui.screens.PauseScreen(true)));
c.waitFor(m->m.gui.screen() instanceof PauseAtlasAccess pause&&pause.sanctuary$pauseMap()!=null&&pause.sanctuary$pauseMap().claimOverlay.get(10,9)!=null,400);
c.runOnClient(m->require(!ChunkPropertyClient.propertiesVisible(),"Hidden state is shared with Pause and retained when the map closes"));
c.waitTicks(3);c.takeScreenshot("claims276-hidden-pause");
c.runOnClient(m->((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap().onPress(ENTER));
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.claimOverlay.get(10,9)!=null&&map.claimOverlay.get(11,8)!=null,400);
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.claims.map_hidden").onPress(ENTER);require(ChunkPropertyClient.propertiesVisible(),"Properties can be shown again with their cached current state");});
c.waitTicks(3);c.takeScreenshot("claims276-restored-map");
c.runOnClient(m->m.gui.setScreen(new net.minecraft.client.gui.screens.PauseScreen(true)));
c.waitFor(m->m.gui.screen() instanceof PauseAtlasAccess pause&&pause.sanctuary$pauseMap()!=null&&pause.sanctuary$pauseMap().claimOverlay.get(10,9)!=null,400);
c.runOnClient(m->{var map=((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap();require(map.claimOverlay.get(10,9).claim().name().equals("Camille"),"Pause preview receives offline ownership with its terrain tiles");var pixels=((net.minecraft.client.renderer.texture.DynamicTexture)field(map,"texture")).getPixels();int ox=(int)field(map,"originX"),oz=(int)field(map,"originZ");int border=0xFF000000|map.claimOverlay.get(11,8).claim().color();require(pixels.getPixel(191-ox,136-oz)==border,"Pause uses the same pixel contour for an unprotected claim");});
c.waitTicks(3);c.takeScreenshot("claims276-pause-preview");
c.runOnClient(m->m.gui.setScreen(null));
}finally{Starter039TestSupport.manual=false;}
}
private static void fixture(ChunkClaims store,UUID owner,int x,int z,boolean protect)throws Exception{
var key=new ChunkClaims.Key(x,z);var tx=store.prepare(owner,"claim",key,store.revision());require(tx!=null,"Fixture chunk is free");store.recover(owner,tx.receipt().toString());
if(protect){tx=store.prepare(owner,"protect",key,store.revision());store.recover(owner,tx.receipt().toString());}
}
private static void checkCache(){
var time=new java.util.concurrent.atomic.AtomicLong();var cache=new ClaimMapOverlay(time::get);UUID id=UUID.randomUUID();var key=new ChunkClaims.Key(-1,2);
var paint=new ChunkPropertyPayloads.Paint(-1,2,id,"Test",0x123456,1000);cache.begin(7);cache.observe(-1,2);cache.accept(new ChunkPropertyPayloads.Overlay(7,List.of(key),List.of(paint)));
require(cache.get(-1,2).claim().color()==0x123456&&cache.remaining(cache.get(-1,2))==1000,"Exact RGB is retained");
time.set(1_001_000_000L);require(cache.remaining(cache.get(-1,2))==0&&cache.get(-1,2)!=null,"Expiration changes style while keeping ownership");require(cache.ink(-1,2).pixel(0,8)==0xFF123456&&cache.ink(-1,2).pixel(8,8)==0&&cache.expiredTiles().contains(key)&&cache.expiredTiles().isEmpty(),"Expiry removes the fill, retains the outline and invalidates once");
cache.begin(8);cache.accept(new ChunkPropertyPayloads.Overlay(7,List.of(key),List.of()));require(cache.get(-1,2)!=null,"Stale viewport cannot erase current claims");
cache.accept(new ChunkPropertyPayloads.Overlay(0,List.of(key),List.of(paint)));require(cache.remaining(cache.get(-1,2))==1000,"Live purchase update survives a pending viewport refresh");
var hidden=new ChunkClaims.Key(30,30);cache.accept(new ChunkPropertyPayloads.Overlay(0,List.of(hidden),List.of(new ChunkPropertyPayloads.Paint(30,30,id,"Hidden",0xFFFFFF,1000))));require(cache.get(30,30)==null,"Live updates cannot reveal an unobserved chunk");
cache.accept(new ChunkPropertyPayloads.Overlay(8,List.of(key),List.of()));require(cache.get(-1,2)==null,"An empty current tile removes stale paint");cache.clear();
var a=new ChunkClaims.Key(0,0);var east=new ChunkClaims.Key(1,0);var south=new ChunkClaims.Key(0,1);var diagonal=new ChunkClaims.Key(1,1);
cache.begin(9);for(var k:List.of(a,east,south,diagonal))cache.observe(k.x(),k.z());
cache.accept(new ChunkPropertyPayloads.Overlay(9,List.of(a,east,south),List.of(new ChunkPropertyPayloads.Paint(0,0,id,"Test",0x123456,0),new ChunkPropertyPayloads.Paint(1,0,id,"Test",0x123456,1000),new ChunkPropertyPayloads.Paint(0,1,id,"Test",0x123456,0))));
require(cache.ink(0,0).pixel(15,8)==0&&cache.ink(0,0).pixel(8,15)==0&&cache.ink(0,0).pixel(15,15)==0xFF123456,"L-shaped property removes internal edges and closes its concave corner");
require(cache.ink(1,0).pixel(0,8)==0x40123456,"Protected fill joins unprotected ownership without a border");
cache.accept(new ChunkPropertyPayloads.Overlay(0,List.of(diagonal),List.of(new ChunkPropertyPayloads.Paint(1,1,id,"Test",0x123456,0))));
require(cache.ink(0,0).pixel(15,15)==0,"Completing a square removes the former concave corner");
cache.accept(new ChunkPropertyPayloads.Overlay(0,List.of(diagonal),List.of()));
require(cache.ink(0,0).pixel(15,15)==0xFF123456,"Removal restores the adjoining corner");
require(ClaimMapOverlay.affected(List.of(diagonal)).contains(a),"Diagonal invalidation includes the affected corner tile");
require(cache.ink(1_875_000,1_875_000).pixel(0,0)==0&&cache.get(1_875_001,0)==null,"World-edge adjacency is bounded");
}
private static int pixel(SanctuaryAtlasScreen map,int x,int z){return ((net.minecraft.client.renderer.texture.DynamicTexture)field(map,"footprintTexture")).getPixels().getPixel(x-(int)field(map,"originX"),z-(int)field(map,"originZ"));}
private static Object field(Object o,String name){try{var f=o.getClass().getDeclaredField(name);f.setAccessible(true);return f.get(o);}catch(Exception e){throw new AssertionError(e);}}
private static ChunkPropertyPayloads.View view(Screen screen){return screen instanceof SanctuaryAtlasScreen map?map.propertyView():null;}
private static void rightClick(Screen screen){var atlas=(SanctuaryAtlasScreen)screen;require(atlas.mouseClicked(new MouseButtonEvent(atlas.width/2.+(152-atlas.centerX())*atlas.zoom(),(atlas.contentTop+atlas.contentBottom)/2.+(136-atlas.centerZ())*atlas.zoom(),new MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_RIGHT,0)),false),"Right click is handled on the terrain");}
private static String key(AbstractWidget w){return w.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
private static java.util.stream.Stream<GuiEventListener> widgets(ContainerEventHandler c){return c.children().stream().flatMap(w->w instanceof ContainerEventHandler nested?java.util.stream.Stream.concat(java.util.stream.Stream.of(w),widgets(nested)):java.util.stream.Stream.of(w));}
private static Button button(Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals(key)).findFirst().orElseThrow(()->new AssertionError("Missing button "+key));}
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,78 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.atlas.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
public final class Demeure274ClientChecks implements FabricClientGameTest {
private static final KeyEvent ENTER=new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);
public void runTest(ClientGameTestContext c){
Starter039TestSupport.manual=true;var armed=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(armed.get()&&m.gui.screen() instanceof HelloWorldScreen s){
Starter039TestSupport.selectFirst(s);widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(ENTER);
button(s,"sanctuary.progression.enter").onPress(ENTER);armed.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Demeure274 "+UUID.randomUUID());s.setSeed("274");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
world.getServer().runOnServer(server->{var p=server.getPlayerList().getPlayers().getFirst();p.teleportTo(128,-40,128);p.setNoGravity(true);server.setDifficulty(net.minecraft.world.Difficulty.PEACEFUL,true);p.setExperienceLevels(20);
require(ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()),"Real Atlas purchase");
for(int z=8;z<=9;z++)for(int x=9;x<=10;x++){
byte[] colors=new byte[256];Arrays.fill(colors,net.minecraft.world.level.material.MapColor.GOLD.getPackedId(net.minecraft.world.level.material.MapColor.Brightness.NORMAL));colors[17]=0;colors[18]=1;
AtlasService.observations(p).observe(new AtlasStore.Tile(x,z,colors));fr.koka.demeure.DemeureService.record(p,new net.minecraft.core.BlockPos(x*16,-40,z*16),8);
}ProgressionService.send(p,"");
});
c.waitFor(m->ProgressionClient.progress.atlas(),200);
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)){
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(scale);m.resizeGui();
m.gui.setScreen(new SanctuaryAtlasScreen(null,new fr.koka.sanctuary.community.CommunityTask.Location("minecraft:overworld",152,-40,160)));
if(ChunkPropertyClient.propertiesVisible())button(m.gui.screen(),"sanctuary.claims.map_visible").onPress(ENTER);
button(m.gui.screen(),"sanctuary.atlas.demeure_off").onPress(ENTER);
});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.footprintCount()>=4&&pixel(map,144,128)!=0,400);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();int tint=pixel(map,144,128);
require(tint==pixel(map,159,132)&&tint==pixel(map,160,132),"Adjacent same-influence chunks have no internal border");
require(tint==pixel(map,159,143)&&tint==pixel(map,160,144),"Corners connect with the same tint");
require(tint==pixel(map,147,131),"Edges, corners and body share one intensity");
String hint=net.minecraft.network.chat.Component.translatable("sanctuary.atlas.demeure_hint").getString().toLowerCase(Locale.ROOT);
require(!hint.contains("claim")&&!hint.contains("protection"),"Demeure help describes habitation");
});
c.waitTicks(3);c.takeScreenshot("demeure274-connected-"+locale+"-gui"+scale);
}
var fixture=c.computeOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();var key=new AtlasStore.Key(10,9);
var homes=(Map<AtlasStore.Key,AtlasDemeurePayload.Footprint>)field(map,"homes");var surveys=(Map<AtlasStore.Key,byte[]>)field(map,"surveyed");
return new Fixture((int)field(map,"token"),key,surveys.get(key).clone(),homes.get(key));
});
byte[] maskedColors=fixture.colors.clone();maskedColors[17]=0;maskedColors[18]=1;
var masked=new Fixture(fixture.token,fixture.key,maskedColors,fixture.home);send(world.getServer(),masked,List.of(fixture.home));
c.waitFor(m->pixel((SanctuaryAtlasScreen)m.gui.screen(),161,145)==0&&pixel((SanctuaryAtlasScreen)m.gui.screen(),162,145)==0,200);
var weaker=new AtlasDemeurePayload.Footprint(10,9,fixture.home.state(),fixture.home.kind(),fixture.home.label(),25,fixture.home.color());
send(world.getServer(),masked,List.of(weaker));
c.waitFor(m->pixel((SanctuaryAtlasScreen)m.gui.screen(),160,144)==(0x28000000|weaker.color()),200);
c.runOnClient(m->require(pixel((SanctuaryAtlasScreen)m.gui.screen(),160,144)!=pixel((SanctuaryAtlasScreen)m.gui.screen(),144,128),"Percentage change alone repaints intensity"));
c.waitTicks(3);c.takeScreenshot("demeure274-influence-strength");
send(world.getServer(),masked,List.of());c.waitFor(m->pixel((SanctuaryAtlasScreen)m.gui.screen(),160,144)==0,200);
c.runOnClient(m->{ProgressionClient.open(m.gui.screen(),"identity");require(button(m.gui.screen(),"sanctuary.property.title")!=null,"Habitant property entry remains in place");});
c.waitTicks(3);c.takeScreenshot("demeure274-habitant-preserved");c.runOnClient(m->m.gui.setScreen(null));
}finally{Starter039TestSupport.manual=false;}
}
private record Fixture(int token,AtlasStore.Key key,byte[] colors,AtlasDemeurePayload.Footprint home){}
private static void send(net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext server,Fixture f,List<AtlasDemeurePayload.Footprint> homes){server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking.send(p,new AtlasPayloads.Response(f.token,false,true,false,false,"ready",List.of(new AtlasStore.Tile(f.key.x(),f.key.z(),f.colors))));net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking.send(p,new AtlasDemeurePayload(f.token,homes));});}
private static int pixel(SanctuaryAtlasScreen map,int x,int z){return ((net.minecraft.client.renderer.texture.DynamicTexture)field(map,"footprintTexture")).getPixels().getPixel(x-(int)field(map,"originX"),z-(int)field(map,"originZ"));}
private static Object field(Object o,String name){try{var f=o.getClass().getDeclaredField(name);f.setAccessible(true);return f.get(o);}catch(Exception e){throw new AssertionError(e);}}
private static String key(AbstractWidget w){return w.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
private static java.util.stream.Stream<GuiEventListener> widgets(ContainerEventHandler c){return c.children().stream().flatMap(w->w instanceof ContainerEventHandler nested?java.util.stream.Stream.concat(java.util.stream.Stream.of(w),widgets(nested)):java.util.stream.Stream.of(w));}
private static Button button(Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals(key)).findFirst().orElseThrow(()->new AssertionError("Missing button "+key));}
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -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);}
}
@@ -132,6 +132,20 @@ public final class Insect245ClientChecks implements FabricClientGameTest {
&& 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);
@@ -174,7 +188,7 @@ public final class Insect245ClientChecks implements FabricClientGameTest {
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("INSECTS250_CLIENT_PASS backend=vulkan 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");
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) {
@@ -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,124 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.property.*;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.network.chat.contents.TranslatableContents;
public final class Map274ClientChecks implements FabricClientGameTest {
private static final KeyEvent ENTER=new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);
public void runTest(ClientGameTestContext c){
Starter039TestSupport.manual=true;var onboarding=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen s){
Starter039TestSupport.selectFirst(s);s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(ENTER);
button(s,"sanctuary.progression.enter").onPress(ENTER);onboarding.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Map274 "+UUID.randomUUID());s.setSeed("274");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(128,-40,128);p.setNoGravity(true);s.setDifficulty(net.minecraft.world.Difficulty.PEACEFUL,true);});
checkCache();
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.getConnection().sendCommand("sanctuary property");});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.renderedPixelAt(152,136)!=0,400);
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.atlas.recenter").onPress(ENTER);button(m.gui.screen(),"sanctuary.atlas.recenter").onPress(ENTER);});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.isComplete()&&map.status().equals("ready"),400);
c.runOnClient(m->{require(!ProgressionClient.progress.atlas(),"Property map does not grant the Atlas aptitude");rightClick(m.gui.screen());});
c.waitFor(m->view(m.gui.screen())!=null,200);
c.runOnClient(m->{var d=view(m.gui.screen());require(d.eligible()&&d.count()==0&&d.cost()==0,"Hello World admits free chunk selection");});
c.waitTicks(3);c.takeScreenshot("map274-map-free");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.claims.claim_at_cost").onPress(ENTER));
c.waitFor(m->view(m.gui.screen())!=null&&view(m.gui.screen()).count()==1,200);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();require(map.claimOverlay.get(9,8)!=null&&map.claimOverlay.remaining(map.claimOverlay.get(9,8))==0,"New ownership appears immediately without reopening the map");});
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().setItem(9,new net.minecraft.world.item.ItemStack(fr.koka.sanctuary.gems.Gems.SAPPHIRE.gem(),64));p.inventoryMenu.broadcastChanges();});
c.waitTicks(5);c.runOnClient(m->{m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));rightClick(m.gui.screen());});
c.waitFor(m->view(m.gui.screen())!=null&&view(m.gui.screen()).sapphires()==64,200);
c.waitTicks(3);c.takeScreenshot("map274-map-sapphires");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.claims.protect_at_cost").onPress(ENTER));
c.waitFor(m->view(m.gui.screen())!=null&&view(m.gui.screen()).message().equals("protected"),200);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();require(map.claimOverlay.remaining(map.claimOverlay.get(9,8))>ChunkClaims.PROTECTION_MILLIS-10_000,"Protection updates the currently open map immediately");});
world.getServer().runOnServer(server->{
var p=server.getPlayerList().getPlayers().getFirst();var identities=ProgressionService.session(server).inhabitants;
UUID offline=UUID.randomUUID();int color=identities.available().getFirst();
try{require(identities.create(offline,"Camille","",color),"Offline owner identity created");identities.arrive(offline);var store=ChunkPropertyService.store(server);
fixture(store,p.getUUID(),10,8,true);fixture(store,p.getUUID(),11,8,false);fixture(store,offline,9,9,true);fixture(store,offline,10,9,true);
}catch(Exception e){throw new RuntimeException(e);}
});
c.runOnClient(m->{m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));button(m.gui.screen(),"sanctuary.atlas.recenter").onPress(ENTER);});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.claimOverlay.get(10,9)!=null&&map.claimOverlay.get(11,8)!=null,400);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();var own=map.claimOverlay.get(9,8);var other=map.claimOverlay.get(10,9);
require(own!=null&&own.claim().color()!=other.claim().color(),"Two owners have their distinct persistent colors before any context click");
require(map.claimOverlay.remaining(own)>0&&map.claimOverlay.remaining(map.claimOverlay.get(11,8))==0,"Same owner has both protected and unprotected chunks");
require(other.claim().name().equals("Camille")&&map.claimOverlay.remaining(other)>0,"Offline owner name, color and active protection arrive with map tiles");
});
c.waitTicks(3);c.takeScreenshot("map274-local-two-owners");
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();require(ChunkPropertyService.sapphires(p)==0,"Map action debits full stack");p.setExperienceLevels(100);require(ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()),"Purchase Atlas normally");ProgressionService.send(p,"");});
c.waitFor(m->ProgressionClient.progress.atlas(),200);c.waitTicks(30);
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 SanctuaryAtlasScreen(null,new fr.koka.sanctuary.community.CommunityTask.Location("minecraft:overworld",152,-40,160)));});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.renderedPixelAt(152,136)!=0,400);
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.claimOverlay.get(10,9)!=null&&map.claimOverlay.get(10,8)!=null,400);
c.waitTicks(3);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();int own=0x40000000|map.claimOverlay.get(9,8).claim().color();int other=0x40000000|map.claimOverlay.get(10,9).claim().color();
require(pixel(map,144,128)==own&&pixel(map,159,132)==own&&pixel(map,160,132)==own,"Protected neighbours use one pixel tint without chunk borders");
require(pixel(map,144,144)==other&&pixel(map,159,148)==other&&pixel(map,160,148)==other,"Offline owner regions connect in the same texture");
require(pixel(map,176,128)==0,"Unprotected declared property has no protection tint");
});
c.takeScreenshot("map274-colors-"+locale+"-gui"+scale);
c.runOnClient(m->rightClick(m.gui.screen()));
c.waitFor(m->view(m.gui.screen())!=null,200);
c.runOnClient(m->{var d=view(m.gui.screen());require(d.x()==9&&d.z()==8,"Ordinary Atlas right-click selects actual chunk without a mode toggle");widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.visible).forEach(b->require(b.getX()>=0&&b.getRight()<=m.gui.screen().width&&b.getBottom()<=m.gui.screen().height,"Visible controls fit"));});
c.waitTicks(3);c.takeScreenshot("map274-map-protected-"+locale+"-gui"+scale);
c.runOnClient(m->{m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));require(m.gui.screen() instanceof SanctuaryAtlasScreen&&view(m.gui.screen())==null,"Escape closes the context panel and keeps the map");});
}
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.claims.map_visible").onPress(ENTER);require(!ChunkPropertyClient.propertiesVisible(),"Properties can be hidden without changing server protection");rightClick(m.gui.screen());});
c.waitFor(m->view(m.gui.screen())!=null,200);
c.runOnClient(m->{require(view(m.gui.screen()).x()==9,"Right-click remains available when claims are hidden");m.gui.screen().keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE,0,0));});
c.waitTicks(3);c.takeScreenshot("map274-hidden-map");
c.runOnClient(m->m.gui.setScreen(new net.minecraft.client.gui.screens.PauseScreen(true)));
c.waitFor(m->m.gui.screen() instanceof PauseAtlasAccess pause&&pause.sanctuary$pauseMap()!=null&&pause.sanctuary$pauseMap().claimOverlay.get(10,9)!=null,400);
c.runOnClient(m->require(!ChunkPropertyClient.propertiesVisible(),"Hidden state is shared with Pause and retained when the map closes"));
c.waitTicks(3);c.takeScreenshot("map274-hidden-pause");
c.runOnClient(m->((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap().onPress(ENTER));
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.claimOverlay.get(10,9)!=null&&map.claimOverlay.get(11,8)!=null,400);
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.claims.map_hidden").onPress(ENTER);require(ChunkPropertyClient.propertiesVisible(),"Properties can be shown again with their cached current state");});
c.waitTicks(3);c.takeScreenshot("map274-restored-map");
c.runOnClient(m->m.gui.setScreen(new net.minecraft.client.gui.screens.PauseScreen(true)));
c.waitFor(m->m.gui.screen() instanceof PauseAtlasAccess pause&&pause.sanctuary$pauseMap()!=null&&pause.sanctuary$pauseMap().claimOverlay.get(10,9)!=null,400);
c.runOnClient(m->{var map=((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap();require(map.claimOverlay.get(10,9).claim().name().equals("Camille"),"Pause preview receives offline ownership with its terrain tiles");});
c.waitTicks(3);c.takeScreenshot("map274-pause-preview");
c.runOnClient(m->m.gui.setScreen(null));
}finally{Starter039TestSupport.manual=false;}
}
private static void fixture(ChunkClaims store,UUID owner,int x,int z,boolean protect)throws Exception{
var key=new ChunkClaims.Key(x,z);var tx=store.prepare(owner,"claim",key,store.revision());require(tx!=null,"Fixture chunk is free");store.recover(owner,tx.receipt().toString());
if(protect){tx=store.prepare(owner,"protect",key,store.revision());store.recover(owner,tx.receipt().toString());}
}
private static void checkCache(){
var time=new java.util.concurrent.atomic.AtomicLong();var cache=new ClaimMapOverlay(time::get);UUID id=UUID.randomUUID();var key=new ChunkClaims.Key(-1,2);
var paint=new ChunkPropertyPayloads.Paint(-1,2,id,"Test",0x123456,1000);cache.begin(7);cache.observe(-1,2);cache.accept(new ChunkPropertyPayloads.Overlay(7,List.of(key),List.of(paint)));
require(cache.get(-1,2).claim().color()==0x123456&&cache.remaining(cache.get(-1,2))==1000,"Exact RGB is retained");
time.set(1_001_000_000L);require(cache.remaining(cache.get(-1,2))==0&&cache.get(-1,2)!=null,"Expiration changes style while keeping ownership");require(cache.tint(-1,2)==0&&cache.expiredTiles().contains(key)&&cache.expiredTiles().isEmpty(),"Expiry invalidates its pixel tile once");
cache.begin(8);cache.accept(new ChunkPropertyPayloads.Overlay(7,List.of(key),List.of()));require(cache.get(-1,2)!=null,"Stale viewport cannot erase current claims");
cache.accept(new ChunkPropertyPayloads.Overlay(0,List.of(key),List.of(paint)));require(cache.remaining(cache.get(-1,2))==1000,"Live purchase update survives a pending viewport refresh");
var hidden=new ChunkClaims.Key(30,30);cache.accept(new ChunkPropertyPayloads.Overlay(0,List.of(hidden),List.of(new ChunkPropertyPayloads.Paint(30,30,id,"Hidden",0xFFFFFF,1000))));require(cache.get(30,30)==null,"Live updates cannot reveal an unobserved chunk");
cache.accept(new ChunkPropertyPayloads.Overlay(8,List.of(key),List.of()));require(cache.get(-1,2)==null,"An empty current tile removes stale paint");cache.clear();
}
private static int pixel(SanctuaryAtlasScreen map,int x,int z){return ((net.minecraft.client.renderer.texture.DynamicTexture)field(map,"footprintTexture")).getPixels().getPixel(x-(int)field(map,"originX"),z-(int)field(map,"originZ"));}
private static Object field(Object o,String name){try{var f=o.getClass().getDeclaredField(name);f.setAccessible(true);return f.get(o);}catch(Exception e){throw new AssertionError(e);}}
private static ChunkPropertyPayloads.View view(Screen screen){return screen instanceof SanctuaryAtlasScreen map?map.propertyView():null;}
private static void rightClick(Screen screen){var atlas=(SanctuaryAtlasScreen)screen;require(atlas.mouseClicked(new MouseButtonEvent(atlas.width/2.+(152-atlas.centerX())*atlas.zoom(),(atlas.contentTop+atlas.contentBottom)/2.+(136-atlas.centerZ())*atlas.zoom(),new MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_RIGHT,0)),false),"Right click is handled on the terrain");}
private static String key(AbstractWidget w){return w.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
private static java.util.stream.Stream<GuiEventListener> widgets(ContainerEventHandler c){return c.children().stream().flatMap(w->w instanceof ContainerEventHandler nested?java.util.stream.Stream.concat(java.util.stream.Stream.of(w),widgets(nested)):java.util.stream.Stream.of(w));}
private static Button button(Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals(key)).findFirst().orElseThrow(()->new AssertionError("Missing button "+key));}
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,111 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.property.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.network.chat.contents.TranslatableContents;
public final class Menu275ClientChecks implements FabricClientGameTest {
private static final KeyEvent ENTER=new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);
public void runTest(ClientGameTestContext c){
Starter039TestSupport.manual=true;var armed=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(armed.get()&&m.gui.screen() instanceof HelloWorldScreen s){
widgets(s).filter(EditBox.class::isInstance).map(EditBox.class::cast).filter(e->key(e).equals("sanctuary.progression.bio")).findFirst().orElseThrow().setValue("Je parcours Sanctuary et je construis mon histoire avec les habitants. Je garde les souvenirs des lieux explorés, des rencontres et des projets du village. ".repeat(3));
Starter039TestSupport.selectFirst(s);widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(ENTER);
button(s,"sanctuary.progression.enter").onPress(ENTER);armed.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Menu275 "+UUID.randomUUID());s.setSeed("275");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
world.getServer().runOnServer(server->{var p=server.getPlayerList().getPlayers().getFirst();p.teleportTo(128,-40,128);p.setNoGravity(true);server.setDifficulty(net.minecraft.world.Difficulty.PEACEFUL,true);
p.getStats().setValue(p,net.minecraft.stats.Stats.CUSTOM.get(net.minecraft.stats.Stats.PLAY_TIME),1875680);
fr.koka.sanctuary.knowledge.BlockKnowledgeService.recordPlaced(p,net.minecraft.world.level.block.Blocks.STONE);
});
c.runOnClient(m->{require(chat(m).stream().noneMatch(t->t.contains("first property is free")||t.contains("première propriété est gratuite")),"No arrival property advertisement");m.getConnection().sendCommand("sanctuary property");});
c.waitFor(m->chat(m).stream().anyMatch(t->t.contains("Atlas to access properties")||t.contains("Carte pour accéder aux propriétés")),200);
c.runOnClient(m->{require(m.gui.screen()==null,"Locked property command does not open a map");m.gui.setScreen(new SanctuaryAtlasScreen(null,true));});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.status().equals("locked"),200);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();require(map.knownPixels()==0,"Retired local-map constructor cannot reveal terrain");int token=(int)field(map,"token");net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking.send(new ChunkPropertyPayloads.MapRequest(token,8,8));});
c.waitTicks(5);c.runOnClient(m->{require(((SanctuaryAtlasScreen)m.gui.screen()).knownPixels()==0,"Legacy map packet is locked on the server");m.gui.setScreen(null);});
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)){
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(scale);m.resizeGui();m.gui.setScreen(new PauseScreen(true));pauseLayout(m.gui.screen(),false);});
c.waitTicks(3);c.takeScreenshot("menu275-locked-"+locale+"-gui"+scale);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.progression.identity").onPress(ENTER));
c.waitFor(m->m.gui.screen() instanceof ProgressionScreen&&InhabitantOverviewClient.data()!=null,300);
c.runOnClient(m->identityLayout(m.gui.screen()));c.waitTicks(3);c.takeScreenshot("menu275-inhabitant-"+locale+"-gui"+scale);
if(locale.equals("fr_fr")&&scale==2){
c.runOnClient(m->button(m.gui.screen(),"sanctuary.cycle.faction").onPress(ENTER));c.waitFor(m->m.gui.screen() instanceof CycleFactionScreen,200);
c.runOnClient(m->{m.gui.screen().onClose();require(m.gui.screen() instanceof ProgressionScreen,"Faction returns to Habitant");button(m.gui.screen(),"sanctuary.familiar.title").onPress(ENTER);});
c.waitFor(m->m.gui.screen() instanceof FamiliarScreen,200);c.runOnClient(m->{m.gui.screen().onClose();require(m.gui.screen() instanceof ProgressionScreen,"Familiar returns to Habitant");});
}
c.runOnClient(m->m.gui.setScreen(new PauseScreen(true)));
}
world.getServer().runOnServer(server->{var p=server.getPlayerList().getPlayers().getFirst();p.setExperienceLevels(20);require(ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()),"Purchase actual Atlas");ProgressionService.send(p,"");});
c.waitFor(m->m.gui.screen() instanceof PauseAtlasAccess p&&p.sanctuary$pauseMap()!=null&&p.sanctuary$pauseMap().knownPixels()>0,400);
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 PauseScreen(true));});
c.waitFor(m->m.gui.screen() instanceof PauseAtlasAccess p&&p.sanctuary$pauseMap()!=null&&p.sanctuary$pauseMap().knownPixels()>0,400);
c.runOnClient(m->pauseLayout(m.gui.screen(),true));c.waitTicks(3);c.takeScreenshot("menu275-atlas-"+locale+"-gui"+scale);
if(locale.equals("fr_fr")&&scale==2){
c.runOnClient(m->button(m.gui.screen(),"menu.options").onPress(ENTER));c.waitFor(m->m.gui.screen() instanceof net.minecraft.client.gui.screens.options.OptionsScreen,200);
c.runOnClient(m->{m.gui.screen().onClose();require(m.gui.screen() instanceof PauseScreen,"Native options return to Pause");});
}
}
c.runOnClient(m->m.getConnection().sendCommand("sanctuary property"));
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.knownPixels()>0,400);
c.runOnClient(m->{var map=(SanctuaryAtlasScreen)m.gui.screen();double x=map.width/2.+(152-map.centerX())*map.zoom(),y=(map.contentTop+map.contentBottom)/2.+(136-map.centerZ())*map.zoom();map.mouseClicked(new MouseButtonEvent(x,y,new MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_RIGHT,0)),false);});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen map&&map.propertyView()!=null,200);
c.runOnClient(m->{require(((SanctuaryAtlasScreen)m.gui.screen()).propertyView().eligible(),"Properties remain available through the unlocked Atlas");m.gui.setScreen(new PauseScreen(true));});
world.getServer().runOnServer(server->{var p=server.getPlayerList().getPlayers().getFirst();require(AptitudeService.set(p,new AptitudeService.Set("atlas",false,AptitudeService.preferences(p).disabledMask())),"Disable Atlas");AptitudeService.send(p);});
c.waitFor(m->m.gui.screen() instanceof PauseAtlasAccess p&&p.sanctuary$pauseMap()==null,200);
c.runOnClient(m->{pauseLayout(m.gui.screen(),false);m.gui.setScreen(null);});
}finally{Starter039TestSupport.manual=false;}
}
private static void identityLayout(Screen s){
require(!has(s,"sanctuary.property.title")&&!has(s,"sanctuary.cycle.log"),"Habitant has neither Property nor the activity history");
var faction=button(s,"sanctuary.cycle.faction");var familiar=button(s,"sanctuary.familiar.title");require(faction.getY()==familiar.getY()&&faction.getRight()<familiar.getX(),"Only Factions and Familiar share the top row");
var skin=widgets(s).filter(PlayerSkinWidget.class::isInstance).map(PlayerSkinWidget.class::cast).findFirst().orElseThrow();
var stats=button(s,"sanctuary.inhabitant.overview.stats");var bio=widget(s,"sanctuary.inhabitant.bio");
require(stats.getRight()<skin.getX()&&bio.getX()>skin.getRight(),"Statistics, character and biography occupy three distinct columns");
require(Math.abs(skin.getX()+skin.getWidth()/2-s.width/2)<=2,"Character is centered with room around it");
require(widgets(s).noneMatch(w->w.getClass().getSimpleName().equals("Activity")||w.getClass().getSimpleName().equals("Frame")),"Activity and opaque statistics frames are absent");
String time=widget(s,"sanctuary.inhabitant.overview.play_time").getMessage().getString();
require((time.contains("1 d")||time.contains("1 j"))&&time.contains("2 h")&&time.contains("min")&&time.contains(" s")&&!time.matches(".*\\d+[.,]\\d+.*"),"Played time uses whole days, hours, minutes and seconds: "+time);
}
private static void pauseLayout(Screen s,boolean atlas){
var map=((PauseAtlasAccess)s).sanctuary$pauseMap();require((map!=null)==atlas,"Pause map matches purchased active Atlas");
String[] keys={"sanctuary.progression.blocks","sanctuary.progression.progress","sanctuary.progression.identity","sanctuary.arena.title"};
var article=widget(s,"sanctuary.community.article");var notice=widget(s,"sanctuary.community.notice");
var adventure=widget(s,"sanctuary.community.pause_group_adventure");var community=widget(s,"sanctuary.community.pause_group_community");var game=widget(s,"sanctuary.community.pause_group_game");
require(adventure.getX()<community.getX(),"Adventure and Community remain distinct left/right groups");
if(atlas){
require(button(s,keys[0]).getX()==button(s,keys[1]).getX()&&button(s,keys[0]).getY()<button(s,keys[1]).getY(),"Discovery and Progression form the Adventure column even in wide view");
require(button(s,keys[2]).getX()==button(s,keys[3]).getX()&&button(s,keys[2]).getY()<button(s,keys[3]).getY(),"Inhabitant and Battles form the Community column");
require(game.getY()>=map.getBottom()&&game.getBottom()<=button(s,"menu.options").getY(),"Game heading is retained above its native actions");
for(String key:keys)require(button(s,key).getBottom()<=map.getY(),"Navigation stays above the map: "+key);
require(button(s,"menu.options").getY()>=map.getBottom()&&button(s,"options.worldOptions.button").getY()>=map.getBottom(),"Native options stay below the map");
var quit=has(s,"menu.returnToMenu")?button(s,"menu.returnToMenu"):button(s,"menu.disconnect");require(quit.getY()>button(s,"menu.options").getY(),"Quit occupies the final central row");
require(article.getRight()<map.getX()&&notice.getX()>map.getRight(),"Gazette is left and Notice board is right of the map");
require(map.getWidth()>0&&map.getHeight()>30,"Map retains a usable central viewport");
}else require(article.getX()<button(s,keys[0]).getX()&&notice.getX()>button(s,keys[2]).getRight(),"Locked layout keeps the original side feeds and central menu");
widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.visible).forEach(b->require(b.getX()>=0&&b.getRight()<=s.width&&b.getY()>=0&&b.getBottom()<=s.height,"Native control fits: "+b.getMessage().getString()));
}
private static List<String> chat(net.minecraft.client.Minecraft m){return ((List<net.minecraft.client.multiplayer.chat.GuiMessage>)field(m.gui.hud.getChat(),"allMessages")).stream().map(t->t.content().getString()).toList();}
private static Object field(Object o,String name){try{var f=o.getClass().getDeclaredField(name);f.setAccessible(true);return f.get(o);}catch(Exception e){throw new AssertionError(e);}}
private static String key(AbstractWidget w){return w.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
private static java.util.stream.Stream<GuiEventListener> widgets(ContainerEventHandler c){return c.children().stream().flatMap(w->w instanceof ContainerEventHandler nested?java.util.stream.Stream.concat(java.util.stream.Stream.of(w),widgets(nested)):java.util.stream.Stream.of(w));}
private static AbstractWidget widget(Screen s,String key){return widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(b->key(b).equals(key)).findFirst().orElseThrow(()->new AssertionError("Missing widget "+key));}
private static Button button(Screen s,String key){return (Button)widget(s,key);}
private static boolean has(Screen s,String key){return widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(b->key(b).equals(key));}
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,151 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.property.*;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.PauseScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
/** Real admission and property packets, plus both locales at the supported GUI scales on Vulkan. */
public final class Property270ClientChecks implements FabricClientGameTest {
private static final KeyEvent ENTER=new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);
public void runTest(ClientGameTestContext c){
Starter039TestSupport.manual=true;var onboarding=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen s){
s.children().stream().filter(EditBox.class::isInstance).map(EditBox.class::cast)
.filter(e->key(e).equals("sanctuary.progression.bio")).findFirst().orElseThrow()
.setValue("Je parcours les îles de Sanctuary, je bâtis une maison près des arbres et je garde les souvenirs de mes voyages. Chaque nouvelle rencontre nourrit mon histoire et mes projets pour le village.");
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);
button(s,"sanctuary.progression.enter").onPress(ENTER);onboarding.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Inhabitant271 "+UUID.randomUUID());s.setSeed("271");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(128,-40,128);p.setNoGravity(true);s.setDifficulty(net.minecraft.world.Difficulty.PEACEFUL,true);
p.awardStat(net.minecraft.stats.Stats.PLAY_TIME,72000);p.awardStat(net.minecraft.stats.Stats.BLOCK_MINED.get(net.minecraft.world.level.block.Blocks.STONE),25);
fr.koka.sanctuary.knowledge.BlockKnowledgeService.recordPlaced(p,net.minecraft.world.level.block.Blocks.STONE);
fr.koka.sanctuary.knowledge.BlockKnowledgeService.recordPlaced(p,net.minecraft.world.level.block.Blocks.COBBLESTONE);
var advancement=s.getAdvancements().get(net.minecraft.resources.Identifier.withDefaultNamespace("story/mine_stone"));
for(var criterion:advancement.value().criteria().keySet())p.getAdvancements().award(advancement,criterion);
});
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.getConnection().sendCommand("sanctuary property");});
c.waitFor(m->m.gui.screen() instanceof PropertyScreen s&&s.data()!=null,200);
c.runOnClient(m->{var s=(PropertyScreen)m.gui.screen();require(s.data().eligible()&&s.data().claim()==null,"Admitted inhabitant has a free property");});
c.runOnClient(m->m.gui.setScreen(null));c.waitFor(m->!CommunityClient.pending(),200);
String publication=UUID.randomUUID().toString();var posted=new AtomicBoolean(false);
c.runOnClient(m->require(CommunityClient.request(null,"create",publication,"","notice","info","Mon atelier près du village","Une annonce pour vérifier mon activité personnelle.",0,0,r->{require(!r.has("error"),"Local development publication commits");posted.set(true);}),"Publication request accepted"));
c.waitFor(m->posted.get(),200);
c.runOnClient(m->m.gui.toastManager().clear());
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 PauseScreen(true));var pause=m.gui.screen();
require(widgets(pause).filter(Button.class::isInstance).map(Button.class::cast).noneMatch(b->key(b).equals("sanctuary.cycle.faction")||key(b).equals("sanctuary.story.title")),"Faction and Story are absent from Pause");
require(button(pause,"sanctuary.arena.title").active,"Battle is restored in Pause");
require(button(pause,"sanctuary.arena.title").getX()==button(pause,"sanctuary.progression.identity").getX()
&&button(pause,"sanctuary.arena.title").getY()>button(pause,"sanctuary.progression.identity").getY(),"Battle belongs to Community below Inhabitant");
});
c.waitTicks(3);c.takeScreenshot("inhabitant271-pause-"+locale+"-gui"+scale);
if(locale.equals("fr_fr")&&scale==2){
c.runOnClient(m->button(m.gui.screen(),"sanctuary.arena.title").onPress(ENTER));
c.waitFor(m->m.gui.screen() instanceof ArenaScreen&&ArenaClient.view!=null,200);
c.runOnClient(m->{m.gui.screen().onClose();require(m.gui.screen() instanceof PauseScreen,"Battle returns to Pause");});
}
c.runOnClient(m->{var pause=m.gui.screen();
ProgressionClient.open(pause,"identity");var identity=m.gui.screen();
identityLayout(identity);
});
c.waitFor(m->InhabitantOverviewClient.data()!=null&&InhabitantOverviewClient.data().events().stream().anyMatch(e->e.id().equals("post:"+publication)),300);
c.runOnClient(m->{var d=InhabitantOverviewClient.data();require(d.playTicks()>=72000&&d.placed()==2&&d.mined()==25&&d.advancements()>0,"Network overview contains real personal counters");identityLayout(m.gui.screen());});
c.waitTicks(3);c.takeScreenshot("inhabitant271-identity-"+locale+"-gui"+scale);
if(scale==4)c.runOnClient(m->{
var screen=(ProgressionScreen)m.gui.screen();var stats=area(screen,"sanctuary.inhabitant.overview.deaths");
stats.setScrollAmount(10000);double offset=stats.scrollAmount();require(offset>0,"Compact statistics have a native independent scroll");
screen.refreshIdentity();require(area(screen,"sanctuary.inhabitant.overview.deaths").scrollAmount()==offset,"Live refresh preserves the statistics reading position");
area(screen,"sanctuary.inhabitant.overview.deaths").setScrollAmount(0);
});
if(locale.equals("fr_fr")&&scale==2){
c.runOnClient(m->button(m.gui.screen(),"sanctuary.cycle.faction").onPress(ENTER));
c.waitFor(m->m.gui.screen() instanceof CycleFactionScreen&&CycleClient.view!=null,200);
c.runOnClient(m->{m.gui.screen().onClose();require(m.gui.screen() instanceof ProgressionScreen,"Faction returns to Habitant");button(m.gui.screen(),"sanctuary.cycle.log").onPress(ENTER);});
c.waitFor(m->m.gui.screen() instanceof InhabitantActivityScreen,200);
c.waitTicks(3);c.takeScreenshot("inhabitant271-activity-history");
c.runOnClient(m->{require(widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w->w.getMessage().getString().contains("Mon atelier")),"Own publication appears in the complete history");button(m.gui.screen(),"sanctuary.inhabitant.overview.cycles").onPress(ENTER);});
c.waitFor(m->m.gui.screen() instanceof CycleFactionScreen&&CycleClient.view!=null,200);
c.runOnClient(m->{m.gui.screen().onClose();require(m.gui.screen() instanceof InhabitantActivityScreen,"Cycle archive returns to activity history");m.gui.screen().onClose();require(m.gui.screen() instanceof ProgressionScreen,"Activity history returns to Habitant");
button(m.gui.screen(),"sanctuary.inhabitant.overview.stats").onPress(ENTER);require(m.gui.screen() instanceof DiscoveryStatsScreen,"Complete statistics are accessible");m.gui.screen().onClose();});
}
c.runOnClient(m->button(m.gui.screen(),"sanctuary.familiar.title").onPress(ENTER));
c.waitFor(m->m.gui.screen() instanceof FamiliarScreen&&CompanionClient.record!=null,200);
c.runOnClient(m->{var screen=m.gui.screen();String consent="sanctuary.companion."+(CompanionClient.record.acceptAllies()?"consent_on":"consent_off");require(button(screen,consent).active,"Familiar consent setting moved into its dedicated page");
require(widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).noneMatch(b->key(b).equals("sanctuary.familiar.title")),"No recursive Familiar button inside its own page");
});
c.waitTicks(3);c.takeScreenshot("inhabitant271-familiar-"+locale+"-gui"+scale);
if(locale.equals("fr_fr")&&scale==2){
c.runOnClient(m->button(m.gui.screen(),"sanctuary.companion.consent_on").onPress(ENTER));
c.waitFor(m->CompanionClient.record!=null&&!CompanionClient.record.acceptAllies()&&hasButton(m.gui.screen(),"sanctuary.companion.consent_off"),200);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();require(!fr.koka.sanctuary.companion.CompanionService.record(p).acceptAllies(),"Moved consent control changes the actual server state");});
c.runOnClient(m->button(m.gui.screen(),"sanctuary.companion.consent_off").onPress(ENTER));
c.waitFor(m->CompanionClient.record!=null&&CompanionClient.record.acceptAllies()&&hasButton(m.gui.screen(),"sanctuary.companion.consent_on"),200);
}
c.runOnClient(m->{m.gui.screen().onClose();require(m.gui.screen() instanceof ProgressionScreen,"Familiar returns to Habitant");identityLayout(m.gui.screen());});
c.runOnClient(m->button(m.gui.screen(),"sanctuary.property.title").onPress(ENTER));
c.waitFor(m->m.gui.screen() instanceof PropertyScreen s&&s.data()!=null,200);
c.waitTicks(3);c.takeScreenshot("inhabitant271-property-"+locale+"-gui"+scale);
c.runOnClient(m->{var s=(PropertyScreen)m.gui.screen();button(s,"sanctuary.property.claim").onPress(ENTER);require(button(s,"sanctuary.property.confirm").active,"Explicit claim confirmation");
widgets(s).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).forEach(a->a.setScrollAmount(10000));
});
c.waitTicks(3);c.takeScreenshot("inhabitant271-confirm-"+locale+"-gui"+scale);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.property.cancel").onPress(ENTER));
}
c.runOnClient(m->{var s=m.gui.screen();button(s,"sanctuary.property.claim").onPress(ENTER);button(s,"sanctuary.property.confirm").onPress(ENTER);});
c.waitFor(m->m.gui.screen() instanceof PropertyScreen s&&s.data()!=null&&s.data().claim()!=null,200);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();require(PropertyService.store(s).get(p.getUUID()).tier()==0,"Real network claim persists");fr.koka.sanctuary.familiar.FamiliarExperience.setPlayerXp(p,150);});
c.runOnClient(m->{m.gui.setScreen(null);m.getConnection().sendCommand("sanctuary propriété");});
c.waitFor(m->m.gui.screen() instanceof PropertyScreen s&&s.data()!=null&&s.data().claim()!=null,200);
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.property.expand").onPress(ENTER);button(m.gui.screen(),"sanctuary.property.confirm").onPress(ENTER);});
c.waitFor(m->m.gui.screen() instanceof PropertyScreen s&&s.data()!=null&&s.data().claim().tier()==1,200);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();require(fr.koka.sanctuary.familiar.FamiliarExperience.playerXp(p)==50,"Real packet purchase debits exactly 100 points");});
c.waitTicks(3);c.takeScreenshot("inhabitant271-expanded");
c.runOnClient(m->m.gui.setScreen(null));
}finally{onboarding.set(false);Starter039TestSupport.manual=false;}
System.out.println("INHABITANT271_CLIENT_PASS: Vulkan, FR/EN GUI 2/3/4, top navigation, two columns and two rows, airy character on the left, full-height activity below, biography and statistics on the right, History at activity footer, familiar hidden, consent round trip, Battle in Community, Story hidden, property regression");
}
private static void identityLayout(Screen s){
var skin=widgets(s).filter(PlayerSkinWidget.class::isInstance).map(PlayerSkinWidget.class::cast).findFirst().orElseThrow();
int contentWidth=Math.min(720,s.width-24),contentX=(s.width-contentWidth)/2,columnWidth=(contentWidth-12)/2;
require(Math.abs(skin.getX()+skin.getWidth()/2-(contentX+columnWidth/2))<=1,"Player is centred in the left column");
require(skin.getWidth()<=columnWidth-24,"Character retains breathing room on both sides");
require(widgets(s).noneMatch(InhabitantCompanionWidget.class::isInstance),"No familiar portrait in Habitant");
require(!hasButton(s,"sanctuary.arena.title")&&!hasButton(s,"sanctuary.companion.consent_on")&&!hasButton(s,"sanctuary.companion.consent_off"),"Only personal navigation remains in Habitant");
int x=-1,navY=button(s,"sanctuary.property.title").getY();
for(String key:List.of("sanctuary.property.title","sanctuary.cycle.faction","sanctuary.familiar.title")){
var b=button(s,key);require(b.active&&b.getY()==navY&&b.getBottom()<skin.getY()&&b.getX()>x,"Three ordered links share the top bar: "+key);
require(b.getX()>=0&&b.getRight()<=s.width,"Identity navigation fits horizontally");x=b.getRight();
}
var history=button(s,"sanctuary.cycle.log");require(history.getX()<skin.getX()&&history.getY()>skin.getBottom()&&history.getBottom()<s.height-33,"History is below the left activity feed and above the footer");
require(history.getBottom()==s.height-41,"Activity footer extends to the bottom content boundary");
var stats=button(s,"sanctuary.inhabitant.overview.stats");require(stats.getX()>skin.getRight()&&stats.getY()>skin.getBottom(),"Statistics occupy the lower right cell");
widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->w.getClass().getSimpleName().equals("Activity"))
.forEach(w->require(w.getY()>skin.getBottom()&&w.getRight()<stats.getX(),"Activity occupies the lower left cell"));
var biography=widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->key(w).equals("sanctuary.inhabitant.bio")).findFirst().orElseThrow();
require(biography.getX()>skin.getRight(),"Biography is on the right of the character");
}
private static boolean hasButton(Screen s,String key){return s!=null&&widgets(s).filter(Button.class::isInstance).map(Button.class::cast).anyMatch(b->key(b).equals(key));}
private static AbstractScrollArea area(Screen s,String key){return widgets(s).filter(w->w instanceof AbstractScrollArea&&w instanceof ContainerEventHandler)
.filter(w->widgets((ContainerEventHandler)w).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(c->key(c).equals(key))).map(AbstractScrollArea.class::cast).findFirst().orElseThrow();}
private static String key(AbstractWidget w){return w.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
private static java.util.stream.Stream<GuiEventListener> widgets(ContainerEventHandler c){return c.children().stream().flatMap(w->w instanceof ContainerEventHandler nested?java.util.stream.Stream.concat(java.util.stream.Stream.of(w),widgets(nested)):java.util.stream.Stream.of(w));}
private static Button button(Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals(key)).findFirst().orElseThrow(()->new AssertionError("Missing button "+key));}
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -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);
@@ -0,0 +1,138 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.client.HelloWorldScreen;
import fr.koka.sanctuary.providence.*;
import fr.koka.sanctuary.client.ProgressionClient;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.ChatScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
/** Native Vulkan chat rendering plus a prayer sent over the real play connection. */
public final class Providence265ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var onboarding=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m -> {
if (onboarding.get() && m.gui.screen() instanceof HelloWorldScreen s) {
Starter039TestSupport.selectFirst(s);
var enter=new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter);
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals("sanctuary.progression.enter"))
.findFirst().orElseThrow().onPress(enter);
onboarding.set(false);
}
});
try (var config=new Providence265TestConfig();
var world=c.worldBuilder().adjustSettings(s -> {
s.setName("Providence265 "+UUID.randomUUID());s.setSeed("265");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
s.setWorldType(s.getNormalPresetList().stream().filter(e -> e.preset()!=null && e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());
}).create()) {
world.getConnection().waitForChunksDownload(2400);
c.waitFor(m -> m.player!=null && ProgressionClient.active() && m.gui.screen()==null,1200);
c.runOnClient(m -> {m.getWindow().setWindowed(1280,960);m.options.guiScale().set(2);m.resizeGui();m.gui.hud.getChat().clearMessages(true);});
var submittedAt=new AtomicLong();
world.getServer().runOnServer(s -> {
s.setDifficulty(net.minecraft.world.Difficulty.PEACEFUL,true);
if(fr.koka.sanctuary.weather.WeatherService.active(s.overworld()))
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather rain");
else {var weather=s.overworld().getWeatherData();weather.setRainTime(10000);weather.setRaining(true);}
submittedAt.set(s.overworld().getGameTime());
});
c.runOnClient(m -> m.getConnection().sendCommand("pray Ô seigneur, j'en ai marre de cet orage, faites revenir le soleil !"));
c.waitTicks(10);
c.runOnClient(m -> {
require(messages(m).stream().noneMatch(message -> message.getContents() instanceof TranslatableContents t && t.getKey().startsWith("sanctuary.providence.")),"No immediate Providence reply");
m.getConnection().sendCommand("pray bonjour");
m.gui.setScreen(new ChatScreen("/pray ",false));
});
world.getServer().runOnServer(s -> require(s.overworld().getWeatherData().isRaining(),"No miracle executes during the response wait"));
c.takeScreenshot("providence267-waiting");
c.waitFor(m -> messages(m).stream().anyMatch(message -> message.getContents() instanceof TranslatableContents t && t.getKey().contains("sga.granted")),300);
world.getServer().runOnServer(s -> {
long elapsed=s.overworld().getGameTime()-submittedAt.get();
require(elapsed>=100 && !s.overworld().getWeatherData().isRaining() && !s.overworld().getWeatherData().isThundering(),"Real network prayer changes weather only after five seconds");
System.out.println("PROVIDENCE267_DELAY_PASS elapsed_server_ticks="+elapsed);
});
c.runOnClient(m -> {
require(messages(m).stream().filter(message -> message.getContents() instanceof TranslatableContents t && t.getKey().contains("sga.granted")).count()==1,"Repeated prayer never replaces or duplicates the delayed reply");
m.gui.setScreen(null);
});
c.runOnClient(m -> m.getConnection().sendCommand("providence readable true"));
c.waitFor(m -> messages(m).stream().anyMatch(message -> message.getContents() instanceof TranslatableContents t && t.getKey().contains("readable.accessibility")),200);
for (String locale:List.of("fr_fr","en_us")) for (int scale:List.of(2,3,4)) for (boolean readable:List.of(false,true)) {
c.runOnClient(m -> {
m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
m.options.guiScale().set(scale);m.resizeGui();m.gui.hud.getChat().clearMessages(true);
for (String key:List.of("granted","deferred","refused","incomprehensible","alternative")) {
var message=ProvidenceMessages.message(key,readable,0);
require(!message.getString().startsWith("sanctuary."),"Translated Providence message");
var expected=switch(key){case "granted"->net.minecraft.ChatFormatting.GREEN;case "deferred"->net.minecraft.ChatFormatting.YELLOW;case "refused"->net.minecraft.ChatFormatting.RED;case "incomprehensible"->net.minecraft.ChatFormatting.GRAY;default->net.minecraft.ChatFormatting.LIGHT_PURPLE;};
require(message.getStyle().getColor()!=null&&message.getStyle().getColor().getValue()==net.minecraft.network.chat.TextColor.fromLegacyFormat(expected).getValue(),"Five distinct verdict colours in SGA and readable mode");
require(!ProvidenceMessages.visible("silent"),"Silent outcome is never added to visible chat");
if (!readable) require(message.getStyle().getFont().toString().contains("providence"),"SGA font retained");
m.gui.hud.getChat().addClientSystemMessage(message);
}
m.gui.setScreen(new ChatScreen("/pray ",false));
});
c.waitTicks(4);c.takeScreenshot("providence265-"+locale+"-gui"+scale+(readable?"-readable":"-sga"));
}
c.runOnClient(m -> {m.gui.setScreen(null);m.getConnection().sendCommand("providence readable false");});
c.waitFor(m -> messages(m).stream().anyMatch(message -> message.getContents() instanceof TranslatableContents t && t.getKey().contains("sga.accessibility")),200);
c.waitTicks(110);
world.getServer().runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();p.setHealth(p.getMaxHealth());p.setInvulnerableTime(0);p.damageCooldownTime=0;
});
c.runOnClient(m -> {
m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(2);m.resizeGui();
m.gui.hud.getChat().clearMessages(true);m.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK);
m.getConnection().sendCommand("pray foudroie @"+m.player.getGameProfile().name());
});
c.waitTicks(10);
world.getServer().runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();require(p.getHealth()==p.getMaxHealth(),"Targeted lightning waits for the same five-second response deadline");});
c.waitFor(m -> !m.level.getEntitiesOfClass(net.minecraft.world.entity.LightningBolt.class,m.player.getBoundingBox().inflate(4)).isEmpty(),300);
c.takeScreenshot("providence268-lightning");
world.getServer().runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();
require(p.getHealth()<p.getMaxHealth(),"Real named prayer damages its target on the server");
require(p.level().getEntitiesOfClass(net.minecraft.world.entity.LightningBolt.class,p.getBoundingBox().inflate(4)).isEmpty(),"Native lightning visual has no server world mutations");
});
c.waitFor(m -> messages(m).stream().anyMatch(message -> message.getContents() instanceof TranslatableContents t && t.getKey().contains("sga.granted")),100);
c.runOnClient(m -> {m.options.setCameraType(net.minecraft.client.CameraType.FIRST_PERSON);m.gui.setScreen(null);});
System.out.println("PROVIDENCE268_LIGHTNING_CLIENT_PASS: actual named network prayer, five-second delay, native visible bolt and server-only target damage");
c.waitTicks(110);
world.getServer().runOnServer(s -> {
try{Providence269GameTests.configure(s,1,1,0,0,5,2);}catch(Exception e){throw new RuntimeException(e);}
var tool=new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.DIAMOND_PICKAXE);tool.setDamageValue(77);
s.getPlayerList().getPlayers().getFirst().setItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND,tool);
});
c.runOnClient(m -> {m.gui.hud.getChat().clearMessages(true);m.getConnection().sendCommand("pray repare mon outil");});
c.waitFor(m -> messages(m).stream().anyMatch(message -> message.getContents() instanceof TranslatableContents t&&t.getKey().contains("sga.deferred")),300);
world.getServer().runOnServer(s -> require(s.getPlayerList().getPlayers().getFirst().getMainHandItem().getDamageValue()==77,"Yellow deferred response precedes the real repair effect"));
c.runOnClient(m -> m.gui.setScreen(new ChatScreen("",false)));c.takeScreenshot("providence269-deferred");c.waitTicks(10);
world.getServer().runOnServer(s -> require(s.getPlayerList().getPlayers().getFirst().getMainHandItem().getDamageValue()==77,"Deferred repair still waits after its first visible response"));
c.waitFor(m -> messages(m).stream().anyMatch(message -> message.getContents() instanceof TranslatableContents t&&t.getKey().contains("sga.granted")),160);
world.getServer().runOnServer(s -> require(s.getPlayerList().getPlayers().getFirst().getMainHandItem().getDamageValue()==0,"Deferred repair actually runs at the later deadline"));
c.takeScreenshot("providence269-granted");c.runOnClient(m -> m.gui.setScreen(null));
System.out.println("PROVIDENCE269_DEFERRED_CLIENT_PASS: real network prayer, five-second response wait, yellow deferral, separate two-second effect wait, real repair and green completion");
System.out.println("PROVIDENCE265_CLIENT_PASS: real network command, server weather, accessibility option, native chat FR/EN GUI 2/3/4 SGA/readable");
} catch (Exception e) { throw new RuntimeException(e); }
finally { onboarding.set(false); }
}
@SuppressWarnings("unchecked") private static List<Component> messages(net.minecraft.client.Minecraft m) {
try {
var state=m.gui.hud.getChat().storeState();var field=state.getClass().getDeclaredField("messages");field.setAccessible(true);
return ((List<net.minecraft.client.multiplayer.chat.GuiMessage>)field.get(state)).stream().map(net.minecraft.client.multiplayer.chat.GuiMessage::content).toList();
} catch(Exception e) { throw new RuntimeException(e); }
}
private static void require(boolean value,String message) { if(!value) throw new AssertionError(message); }
}
@@ -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,90 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.property.*;
import fr.koka.sanctuary.progression.ProgressionService;
import fr.koka.sanctuary.familiar.FamiliarExperience;
import fr.koka.sanctuary.gems.Gems;
import java.nio.file.Files;
import java.util.Arrays;
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.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.LevelResource;
public final class ChunkClaims272GameTests {
private static ServerPlayer admitted(GameTestHelper h,int x,int z)throws Exception{
var p=Progression003GameTests.player(h);var s=ProgressionService.session(h.getLevel().getServer());
s.inhabitants.create(p.getUUID(),"ClaimsTest","",s.inhabitants.available().getFirst());s.inhabitants.arrive(p.getUUID());
p.setAttached(ProgressionService.RECORD,fr.koka.sanctuary.progression.ProgressRecord.INITIAL.json());
fr.koka.sanctuary.progression.ProgressEffects.apply(p,fr.koka.sanctuary.progression.ProgressRecord.INITIAL);
p.setExperienceLevels(20);
if(!ProgressionService.purchase(p,"atlas",fr.koka.sanctuary.progression.ProgressEffects.get(p).revision()))throw new AssertionError("Claim fixtures require the purchased Atlas");
FamiliarExperience.setPlayerXp(p,0);
p.setPos(x*16+.5,80,z*16+.5);p.level().getChunk(x,z);
fr.koka.demeure.DemeureService.record(p,p.blockPosition(),1);return p;
}
private static String act(ServerPlayer p,String action,int x,int z){
return ChunkPropertyService.act(p,action,new ChunkClaims.Key(x,z),ChunkPropertyService.store(p.level().getServer()).revision());
}
@GameTest public void nativeXpSapphirePurchasesAndReceipts(GameTestHelper h)throws Exception{
var p=admitted(h,300,300);var s=ChunkPropertyService.store(p.level().getServer());
var guest=Progression003GameTests.player(h);h.assertTrue(!ChunkPropertyService.eligible(guest),"Admission is required");
for(int i=0;i<5;i++){p.level().getChunk(300+i,300);fr.koka.demeure.DemeureService.record(p,new BlockPos((300+i)*16,80,300*16),1);h.assertTrue(act(p,"claim",300+i,300).equals("claimed"),"Five free inhabited chunks need no XP");}
long rev=s.revision();p.level().getChunk(305,300);
fr.koka.demeure.DemeureService.record(p,new BlockPos(305*16,80,300*16),1);
h.assertTrue(act(p,"claim",305,300).equals("xp")&&s.revision()==rev&&s.count(p.getUUID())==5,"Sixth claim requires 100 points without a reservation on refusal");
FamiliarExperience.setPlayerXp(p,500);h.assertTrue(act(p,"claim",305,300).equals("claimed")&&FamiliarExperience.playerXp(p)==400,"Sixth chunk debits 100 native XP");
p.level().getChunk(306,300);fr.koka.demeure.DemeureService.record(p,new BlockPos(306*16,80,300*16),1);h.assertTrue(act(p,"claim",306,300).equals("claimed")&&FamiliarExperience.playerXp(p)==200,"Seventh debits 200 points");
h.assertTrue(ChunkPropertyService.act(p,"claim",new ChunkClaims.Key(307,300),rev).equals("changed")&&FamiliarExperience.playerXp(p)==200,"Stale request cannot charge again");
h.assertTrue(act(p,"protect",300,300).equals("sapphires")&&s.get(new ChunkClaims.Key(300,300)).until()==0,"No coverage without sapphires");
p.getInventory().setItem(9,new ItemStack(Gems.SAPPHIRE.gem(),4));p.getInventory().setItem(10,new ItemStack(Gems.SAPPHIRE.gem(),3));
h.assertTrue(act(p,"protect",300,300).equals("sapphires")&&ChunkPropertyService.sapphires(p)==7,"Seven sapphires remain untouched");
p.getInventory().setItem(11,new ItemStack(Gems.SAPPHIRE.gem(),2));
h.assertTrue(act(p,"protect",300,300).equals("protected")&&ChunkPropertyService.sapphires(p)==1,"Exactly eight sapphires consumed across stacks");
var key=new ChunkClaims.Key(300,300);long until=s.get(key).until();
h.assertTrue(until-s.now()>3L*24*60*60*1000-10_000&&until-s.now()<=3L*24*60*60*1000&&s.get(new ChunkClaims.Key(301,300)).until()==0,"Three days are per chunk");
var file=p.level().getServer().getWorldPath(LevelResource.PLAYER_DATA_DIR).resolve(p.getStringUUID()+".dat");
var nbt=net.minecraft.nbt.NbtIo.readCompressed(file,net.minecraft.nbt.NbtAccounter.create(32L*1024*1024));
h.assertTrue(nbt.toString().contains(p.getAttachedOrElse(ChunkPropertyService.RECEIPT,"missing")),"Native player receipt is durable before registry commit");
ChunkPropertyService.recover(p);h.assertTrue(s.get(key).until()==until&&ChunkPropertyService.sapphires(p)==1,"Repeated recovery cannot renew or charge again");
p.getInventory().setItem(12,new ItemStack(Gems.SAPPHIRE.gem(),8));
h.assertTrue(act(p,"protect",300,300).equals("protected")&&ChunkPropertyService.sapphires(p)==1&&s.get(key).until()==until+3L*24*60*60*1000,"Another eight sapphires append exactly three days without losing existing coverage");
h.succeed();
}
@GameTest public void nativeDurationUsesActualChunkAndKeepsInteractionsFree(GameTestHelper h)throws Exception{
var owner=admitted(h,340,340);var other=admitted(h,340,340);var s=ChunkPropertyService.store(owner.level().getServer());
h.assertTrue(act(owner,"claim",340,340).equals("claimed"),"Reserve chunk");
var pos=new BlockPos(340*16+15,80,340*16+2);var state=Blocks.STONE.defaultBlockState();owner.level().setBlockAndUpdate(pos,state);
float ownBase=state.getDestroyProgress(owner,owner.level(),pos),otherBase=state.getDestroyProgress(other,other.level(),pos);
owner.getInventory().setItem(9,new ItemStack(Gems.SAPPHIRE.gem(),64));h.assertTrue(act(owner,"protect",340,340).equals("protected"),"Activate protection");
h.assertTrue(state.getDestroyProgress(owner,owner.level(),pos)==ownBase*2&&state.getDestroyProgress(other,other.level(),pos)==otherBase*.5f,"Real native mining duration hook multiplies once");
h.assertTrue(state.getDestroyProgress(other,other.level(),pos.east())==otherBase,"Adjacent chunk has no slowdown");
h.assertTrue(ChunkPropertyService.multiplier(owner,owner.level(),pos.above(100))==2&&ChunkPropertyService.multiplier(other,other.level(),pos.below(100))==.5f,"Chunk column covers all build heights");
h.assertTrue(owner.level().mayInteract(other,pos)&&other.mayUseItemAt(pos,net.minecraft.core.Direction.UP,new ItemStack(Items.COBBLESTONE)),"Claim does not deny interactions and placement");
h.assertTrue(other.gameMode.destroyBlock(pos),"Other player can still finish breaking a protected block");
h.assertTrue(act(other,"protect",340,340).equals("not_owner"),"Other player cannot buy protection on someone else's claim");
h.succeed();
}
@GameTest public void voluntaryLegacyMigrationRefundsAndPreservesOriginalFile(GameTestHelper h)throws Exception{
var owner=admitted(h,380,380);var other=admitted(h,380,380);var legacy=PropertyService.store(owner.level().getServer());var at=owner.blockPosition();
h.assertTrue(PropertyService.act(owner,"claim",-1).equals("claimed"),"Legacy fixture reserves volume");FamiliarExperience.setPlayerXp(owner,500);
h.assertTrue(PropertyService.act(owner,"expand",0).equals("expanded")&&FamiliarExperience.playerXp(owner)==400,"Legacy fixture has a paid extension");
var path=owner.level().getServer().getWorldPath(LevelResource.ROOT).resolve("sanctuary/properties-v1.json");byte[] before=Files.readAllBytes(path);
h.assertTrue(!PropertyService.mayModify(owner.level(),other,at)&&act(other,"claim",380,380).equals("legacy_overlap"),"Legacy rights stay active before migration");
h.assertTrue(act(owner,"claim",381,380).equals("legacy"),"Owner must explicitly migrate");
h.assertTrue(act(owner,"migrate",380,380).equals("migrated")&&FamiliarExperience.playerXp(owner)==500,"Confirmed migration refunds exact tier XP");
h.assertTrue(Arrays.equals(before,Files.readAllBytes(path))&&legacy.get(owner.getUUID()).tier()==1,"Legacy schema/file is preserved");
h.assertTrue(PropertyService.mayModify(owner.level(),other,at)&&ChunkPropertyService.store(owner.level().getServer()).count(owner.getUUID())==0,"Retirement removes hard protection without assigning chunks");
h.assertTrue(act(owner,"migrate",380,380).equals("changed")&&FamiliarExperience.playerXp(owner)==500,"No duplicate refund");
h.assertTrue(act(owner,"claim",380,380).equals("claimed"),"Owner chooses first free chunk after migration");h.succeed();
}
@GameTest public void serverRejectsRemoteUndiscoveredAndSpawnClaims(GameTestHelper h)throws Exception{
var p=admitted(h,420,420);var s=ChunkPropertyService.store(p.level().getServer());
h.assertTrue(act(p,"claim",1600,1600).equals("undiscovered")&&s.count(p.getUUID())==0,"Forged remote chunk is refused without loading it");
var spawn=p.level().getServer().getRespawnData().pos();int x=spawn.getX()>>4,z=spawn.getZ()>>4;p.setPos(spawn.getX(),80,spawn.getZ());p.level().getChunk(x,z);
h.assertTrue(act(p,"claim",x,z).equals("spawn")&&s.count(p.getUUID())==0,"Spawn reservation cannot become private");h.succeed();
}
}
@@ -0,0 +1,105 @@
package fr.koka.sanctuary.gametest;
import fr.koka.demeure.DemeureService;
import fr.koka.sanctuary.arena.*;
import fr.koka.sanctuary.property.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.gems.Gems;
import java.util.*;
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;
public final class ClaimBattle278GameTests {
private static final class Fixture implements AutoCloseable{
final GameTestHelper h;final ChunkClaims.Key key;final ChunkClaims claims;final ServerPlayer owner,challenger;
final List<ServerPlayer> players=new ArrayList<>();final long paid;
Fixture(GameTestHelper h,int x)throws Exception{
this.h=h;key=new ChunkClaims.Key(x,700);owner=player();challenger=player();claims=ChunkPropertyService.store(h.getLevel().getServer());
var tx=claims.prepare(owner.getUUID(),"claim",key,claims.revision());claims.recover(owner.getUUID(),tx.receipt().toString());
DemeureService.record(owner,owner.blockPosition(),1);DemeureService.record(challenger,challenger.blockPosition(),1);
owner.getInventory().setItem(9,new ItemStack(Gems.SAPPHIRE.gem(),8));h.assertTrue(ChunkPropertyService.act(owner,"protect",key,claims.revision()).equals("protected"),"Fixture protection is paid through the real service");paid=claims.get(key).until();
}
ServerPlayer player()throws Exception{
var p=Progression003GameTests.player(h);var server=h.getLevel().getServer();var identities=ProgressionService.session(server).inhabitants;
identities.create(p.getUUID(),"Battle"+p.getStringUUID().substring(0,8),"",identities.available().getFirst());identities.arrive(p.getUUID());
p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);p.setExperienceLevels(20);
h.assertTrue(ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()),"Purchase actual Atlas");FamiliarExperience.setPlayerXp(p,500);
p.setPos(key.x()*16+2+players.size()*2,80,key.z()*16+2);p.setNoGravity(true);p.level().getChunk(key.x(),key.z());p.getAbilities().invulnerable=false;
p.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());
server.getPlayerList().getPlayers().add(p);server.getPlayerList().getPlayersByUUID().put(p.getUUID(),p);players.add(p);return p;
}
ClaimDuels.Offer propose(){h.assertTrue(ClaimDuels.propose(challenger,key,claims.revision()).equals("sent"),"Challenger explicitly proposes the immutable property stake");return ClaimDuels.at(owner.level().getServer(),key);}
Arenas.Match agree(){var o=propose();h.assertTrue(ClaimDuels.accept(owner,o.id).equals("accepted"),"Only the owner explicitly accepts the same stake");return o.match;}
void start(Arenas.Match m){for(var p:List.of(owner,challenger))Arenas.ready(m,p);h.assertTrue(Arenas.start(m,challenger).equals("started"),"Both Ready confirmations are required by native Battle");}
void fight(Arenas.Match m){start(m);m.until=FamiliarBattle.now();Arenas.tick(owner.level().getServer());h.assertTrue(m.phase==Arenas.Phase.FIGHT,"Native countdown transitions to combat");}
public void close(){for(var p:players){var m=Arenas.match(p);if(m!=null)Arenas.finish(m,null,"interrupted");var o=ClaimDuels.at(p.level().getServer(),key);if(o!=null&&o.pending())ClaimDuels.cancel(p,o.id);p.level().getServer().getPlayerList().getPlayers().remove(p);p.level().getServer().getPlayerList().getPlayersByUUID().remove(p.getUUID());}}
}
@GameTest public void invitationAndConsentCannotBeForged(GameTestHelper h)throws Exception{
try(var f=new Fixture(h,700)){
var third=f.player();var o=f.propose();
h.assertTrue(Arenas.match(f.challenger)==null&&!ClaimDuels.locked(f.owner.level().getServer(),f.key)&&f.claims.get(f.key).owner().equals(f.owner.getUUID()),"A proposal alone neither starts combat nor moves ownership");
h.assertTrue(ClaimDuels.accept(f.challenger,o.id).equals("unavailable")&&ClaimDuels.accept(third,o.id).equals("unavailable"),"Neither challenger nor third party can forge the owner's agreement");
h.assertTrue(ClaimDuels.cancel(third,o.id).equals("unavailable"),"A third party cannot decide the proposal");
h.assertTrue(!ChunkPropertyService.transferDuel(f.owner.level().getServer(),f.key,f.owner.getUUID(),f.challenger.getUUID(),f.paid),"An ordinary result without durable consent cannot authorize transfer");
h.assertTrue(ClaimDuels.cancel(f.owner,o.id).equals("declined")&&f.claims.get(f.key).owner().equals(f.owner.getUUID()),"Owner refusal leaves claim and paid coverage intact");
DemeureService.record(third,third.blockPosition(),1);h.assertTrue(ClaimDuels.propose(third,f.key,f.claims.revision()).equals("sent"),"A refusal does not reserve the property against another resident");
var latest=ClaimDuels.at(f.owner.level().getServer(),f.key);ClaimDuels.cancel(f.owner,latest.id);
h.assertTrue(!latest.id.equals(o.id)&&ClaimDuels.at(f.owner.level().getServer(),f.key).id.equals(latest.id),"Reopening shows the most recent completed proposal");
}h.succeed();
}
@GameTest public void realPlayerBattleWinnerReceivesOnlyTheAgreedClaim(GameTestHelper h)throws Exception{
try(var f=new Fixture(h,702)){
var third=f.player();var m=f.agree();
h.assertTrue(m.rules.mode()==Arenas.Mode.PLAYERS&&m.rules.duel()&&m.entry==0&&m.entrants.size()==2,"Real private player duel without extra entry charge");
h.assertTrue(Arenas.join(m,third).equals("unavailable")&&Arenas.start(m,f.challenger).equals("not_ready"),"No third entrant and no start before both Ready");
h.assertTrue(FamiliarExperience.playerXp(f.challenger)==500&&FamiliarExperience.playerXp(f.owner)==500,"Consent takes no XP or new sapphires");
f.fight(m);f.owner.setInvulnerableTime(0);
h.assertTrue(f.owner.hurtServer(f.owner.level(),f.owner.damageSources().playerAttack(f.challenger),100),"Native PvP damage reaches the agreed rival");
h.assertTrue(!f.owner.isAlive()&&m.phase==Arenas.Phase.FINISHED&&m.winner.equals(f.challenger.getUUID()),"Native death resolves Battle");
h.assertTrue(f.claims.get(f.key).owner().equals(f.challenger.getUUID())&&f.claims.get(f.key).until()==f.paid,"Winner receives the exact claim with the original paid expiry");
h.assertTrue(f.claims.count(f.owner.getUUID())==0&&f.claims.count(f.challenger.getUUID())==1&&!ClaimDuels.locked(f.owner.level().getServer(),f.key),"Settlement updates both counts and releases the stake");
long revision=f.claims.revision();Arenas.finish(m,f.challenger.getUUID(),"death");h.assertTrue(f.claims.revision()==revision,"Repeated finish cannot repeat the transfer");
}h.succeed();
}
@GameTest public void defenderKeepsProtectionWhenChallengerForfeitsAfterLaunch(GameTestHelper h)throws Exception{
try(var f=new Fixture(h,704)){
var m=f.agree();f.start(m);Arenas.withdraw(f.challenger);
h.assertTrue(m.phase==Arenas.Phase.FINISHED&&m.winner.equals(f.owner.getUUID()),"Battle forfeit after launch awards defender victory");
h.assertTrue(f.claims.get(f.key).owner().equals(f.owner.getUUID())&&f.claims.get(f.key).until()==f.paid,"Defender victory never resets or consumes paid protection");
}h.succeed();
}
@GameTest public void cancellationAndDrawLeaveOwnershipUnchanged(GameTestHelper h)throws Exception{
try(var f=new Fixture(h,706)){
var m=f.agree();h.assertTrue(ClaimDuels.cancel(f.owner,m.id).equals("cancelled")&&m.phase==Arenas.Phase.FINISHED,"Either participant can cancel before launch");
h.assertTrue(f.claims.get(f.key).owner().equals(f.owner.getUUID())&&!ClaimDuels.locked(f.owner.level().getServer(),f.key),"Cancelled mutual agreement grants no claim");
}
try(var f=new Fixture(h,707)){
var m=f.agree();f.fight(m);m.until=FamiliarBattle.now();Arenas.tick(f.owner.level().getServer());
h.assertTrue(m.phase==Arenas.Phase.FINISHED&&m.winner==null&&m.reason.equals("draw"),"Native time limit without elimination is a draw");
h.assertTrue(f.claims.get(f.key).owner().equals(f.owner.getUUID())&&f.claims.get(f.key).until()==f.paid,"A draw transfers no property");
}h.succeed();
}
@GameTest public void changedOrExpiredProposalRequiresNewConsent(GameTestHelper h)throws Exception{
try(var f=new Fixture(h,708)){
var o=f.propose();f.owner.getInventory().setItem(9,new ItemStack(Gems.SAPPHIRE.gem(),8));
h.assertTrue(ChunkPropertyService.act(f.owner,"protect",f.key,f.claims.revision()).equals("protected"),"An unaccepted invitation does not freeze the owner's rights");
h.assertTrue(ClaimDuels.accept(f.owner,o.id).equals("changed")&&Arenas.match(f.owner)==null,"Changed protection terms cannot reuse previous challenger consent");
}
try(var f=new Fixture(h,709)){
var o=f.propose();var deadline=ClaimDuels.Offer.class.getDeclaredField("expires");deadline.setAccessible(true);deadline.setLong(o,FamiliarBattle.now()-1);
h.assertTrue(ClaimDuels.accept(f.owner,o.id).equals("unavailable")&&f.claims.get(f.key).owner().equals(f.owner.getUUID()),"An expired invitation cannot arm a transfer");
}h.succeed();
}
@GameTest public void ordinaryBattleRetainsPublicRosterAndNeverTransfersProperty(GameTestHelper h)throws Exception{
try(var f=new Fixture(h,710)){
var third=f.player();h.assertTrue(Arenas.create(f.owner,new Arenas.Rules(Arenas.Mode.PLAYERS,false,true,48,180),-1,0).equals("created"),"Ordinary public Battle still opens");
var m=Arenas.match(f.owner);h.assertTrue(Arenas.join(m,f.challenger).equals("joined")&&Arenas.join(m,third).equals("joined"),"Public arena still accepts a third participant");
for(var e:m.entrants.values())Arenas.ready(m,e.player);h.assertTrue(Arenas.start(m,f.owner).equals("started"),"Ordinary unanimous start stays available");
Arenas.eliminate(f.owner,"forfeit");Arenas.eliminate(third,"forfeit");
h.assertTrue(m.winner.equals(f.challenger.getUUID())&&f.claims.get(f.key).owner().equals(f.owner.getUUID()),"Winning another Battle never creates a property entitlement");
}h.succeed();
}
}
@@ -0,0 +1,62 @@
package fr.koka.sanctuary.gametest;
import fr.koka.demeure.DemeureService;
import fr.koka.sanctuary.property.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.familiar.FamiliarExperience;
import fr.koka.sanctuary.gems.Gems;
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.item.ItemStack;
import net.minecraft.world.level.block.Blocks;
public final class ClaimDemeure277GameTests {
private static ServerPlayer resident(GameTestHelper h,int x,int z)throws Exception{
var p=Progression003GameTests.player(h);var identities=ProgressionService.session(h.getLevel().getServer()).inhabitants;
identities.create(p.getUUID(),"Resident"+p.getStringUUID().substring(0,8),"",identities.available().getFirst());identities.arrive(p.getUUID());
p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);
p.setExperienceLevels(20);if(!ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()))throw new AssertionError("Purchase Atlas");
FamiliarExperience.setPlayerXp(p,500);p.setPos(x*16+.5,80,z*16+.5);p.level().getChunk(x,z);return p;
}
private static String act(ServerPlayer p,String action,ChunkClaims.Key key){return ChunkPropertyService.act(p,action,key,ChunkPropertyService.store(p.level().getServer()).revision());}
private static void inhabit(ServerPlayer p,ChunkClaims.Key key,double amount){DemeureService.record(p,new BlockPos(key.x()*16,80,key.z()*16),amount);}
@GameTest public void successfulActivityRequiredBeforeFreeAndPaidClaims(GameTestHelper h)throws Exception{
var p=resident(h,600,600);var key=new ChunkClaims.Key(600,600);var store=ChunkPropertyService.store(p.level().getServer());long revision=store.revision();
h.assertTrue(act(p,"claim",key).equals("habitation_required")&&store.revision()==revision&&FamiliarExperience.playerXp(p)==500,"No activity refuses even the first free claim without mutation");
inhabit(p,key,.01);h.assertTrue(act(p,"claim",key).equals("habitation_required"),"A mere passage is insufficient");
for(int i=0;i<10;i++){var pos=new BlockPos(600*16+i,80,600*16+2);p.level().setBlockAndUpdate(pos,Blocks.STONE.defaultBlockState());h.assertTrue(p.gameMode.destroyBlock(pos),"Successful native mining contributes to Demeure");}
h.assertTrue(ChunkPropertyService.residency(p,key).reason().equals("habitation_ready")&&act(p,"claim",key).equals("claimed"),"Actual successful activities reach native habitation threshold");
for(int i=1;i<5;i++){var next=new ChunkClaims.Key(600+i,600);p.level().getChunk(next.x(),next.z());inhabit(p,next,1);h.assertTrue(act(p,"claim",next).equals("claimed"),"Five inhabited claims are still free");}
var paid=new ChunkClaims.Key(605,600);p.level().getChunk(paid.x(),paid.z());revision=store.revision();
h.assertTrue(act(p,"claim",paid).equals("habitation_required")&&store.revision()==revision&&FamiliarExperience.playerXp(p)==500,"Missing habitation cannot debit the sixth claim");
inhabit(p,paid,1);h.assertTrue(act(p,"claim",paid).equals("claimed")&&FamiliarExperience.playerXp(p)==400,"Qualified sixth claim costs exactly one hundred XP");h.succeed();
}
@GameTest public void strongerLaterResidentCanClaimButInfluenceNeverTransfersOwnedLand(GameTestHelper h)throws Exception{
var a=resident(h,620,620);var b=resident(h,620,620);var key=new ChunkClaims.Key(620,620);var store=ChunkPropertyService.store(a.level().getServer());
inhabit(a,key,1);inhabit(b,key,1);
h.assertTrue(ChunkPropertyService.residency(a,key).reason().equals("habitation_ready")&&act(b,"claim",key).equals("habitation_priority"),"Equal influence favors the first inhabitant");
inhabit(b,key,1);long revision=store.revision();
h.assertTrue(act(a,"claim",key).equals("habitation_priority")&&store.revision()==revision&&FamiliarExperience.playerXp(a)==500,"A stronger later inhabitant removes first resident's claim priority without debit");
h.assertTrue(act(b,"claim",key).equals("claimed")&&store.get(key).owner().equals(b.getUUID()),"Later stronger inhabitant actually claims the free spot");
inhabit(a,key,4);h.assertTrue(act(a,"claim",key).equals("taken")&&store.get(key).owner().equals(b.getUUID()),"Increasing influence cannot seize a claim that was already purchased");
b.getInventory().setItem(9,new ItemStack(Gems.SAPPHIRE.gem(),8));h.assertTrue(act(b,"protect",key).equals("protected")&&ChunkPropertyService.sapphires(b)==0,"Current owner renews protection even after losing influence leadership");
h.assertTrue(ChunkPropertyService.multiplier(b,b.level(),b.blockPosition())==2&&ChunkPropertyService.multiplier(a,a.level(),a.blockPosition())==.5f,"Paid mining effects still follow legal ownership");h.succeed();
}
@GameTest public void previousClaimsWithoutHabitationKeepTheirRights(GameTestHelper h)throws Exception{
var p=resident(h,640,640);var key=new ChunkClaims.Key(640,640);var store=ChunkPropertyService.store(p.level().getServer());
var tx=store.prepare(p.getUUID(),"claim",key,store.revision());store.recover(p.getUUID(),tx.receipt().toString());
h.assertTrue(ChunkPropertyService.residency(p,key).reason().equals("habitation_required"),"Legacy fixture has no retroactive Demeure evidence");
p.getInventory().setItem(9,new ItemStack(Gems.SAPPHIRE.gem(),8));h.assertTrue(act(p,"protect",key).equals("protected")&&store.get(key).owner().equals(p.getUUID()),"Previously acquired claims stay owned and renewable");h.succeed();
}
@GameTest public void inaccessibleChunksRevealNoResidentAndResidencyCodecIsBounded(GameTestHelper h)throws Exception{
var a=resident(h,660,660);var b=resident(h,760,760);var key=new ChunkClaims.Key(660,660);inhabit(a,key,1);
var hidden=ChunkPropertyService.residency(b,key);h.assertTrue(hidden.reason().equals("undiscovered")&&hidden.name().isEmpty(),"An arbitrary refresh cannot disclose unknown habitation");
var visible=ChunkPropertyService.residency(a,key);var buffer=new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(),h.getLevel().registryAccess());
try{ChunkPropertyPayloads.Residency.CODEC.encode(buffer,visible);h.assertTrue(ChunkPropertyPayloads.Residency.CODEC.decode(buffer).equals(visible)&&buffer.readableBytes()==0,"Separate residency codec round trips actual priority and identity");}finally{buffer.release();}
boolean refused=false;try{new ChunkPropertyPayloads.Residency(0,0,"invented","",0);}catch(IllegalArgumentException e){refused=true;}h.assertTrue(refused,"Unknown status cannot enter the presentation contract");
AptitudeService.set(a,new AptitudeService.Set("atlas",false,AptitudeService.preferences(a).disabledMask()));
h.assertTrue(ChunkPropertyService.residency(a,key).name().isEmpty()&&act(a,"claim",key).equals("atlas_required"),"No residency or claim access before an active purchased Atlas");h.succeed();
}
}
@@ -0,0 +1,27 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.property.*;
import fr.koka.sanctuary.progression.ProgressionService;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.network.RegistryFriendlyByteBuf;
public final class ClaimMap273GameTests {
@GameTest public void offlineIdentityColorAndBoundedOverlayCodec(GameTestHelper h)throws Exception{
var viewer=Progression003GameTests.player(h);var session=ProgressionService.session(h.getLevel().getServer());
UUID offline=UUID.randomUUID();int color=session.inhabitants.available().getFirst();
h.assertTrue(session.inhabitants.create(offline,"OfflineOwner","",color),"Offline identity fixture uses persistent Hello World palette");session.inhabitants.arrive(offline);
var key=new ChunkClaims.Key(-20,40);var claim=new ChunkClaims.Claim(key,offline,6000);
var paint=ChunkPropertyService.appearance(viewer,claim,1000);
h.assertTrue(paint.color()==color&&paint.name().equals("OfflineOwner")&&paint.remaining()==5000,"Offline owner retains their real name, color and protection time");
h.assertTrue(ChunkPropertyService.appearance(viewer,claim,7000).remaining()==0,"Expired appearance retains identity with no protection");
var payload=new ChunkPropertyPayloads.Overlay(42,List.of(key),List.of(paint));
var buffer=new RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(),h.getLevel().registryAccess());
try{ChunkPropertyPayloads.Overlay.CODEC.encode(buffer,payload);h.assertTrue(ChunkPropertyPayloads.Overlay.CODEC.decode(buffer).equals(payload),"Bounded packet preserves exact owner RGB and chunk coordinates");}finally{buffer.release();}
boolean refused=false;try{new ChunkPropertyPayloads.Overlay(42,List.of(new ChunkClaims.Key(0,0)),List.of(paint));}catch(IllegalArgumentException expected){refused=true;}
h.assertTrue(refused,"No claim may be sent outside its transmitted map tiles");
refused=false;try{new ChunkPropertyPayloads.Overlay(42,java.util.stream.IntStream.range(0,65).mapToObj(i->new ChunkClaims.Key(i,0)).toList(),List.of());}catch(IllegalArgumentException expected){refused=true;}
h.assertTrue(refused,"Overlay batches are limited to 64 tiles");h.succeed();
}
}
@@ -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,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; }
}
@@ -0,0 +1,55 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.inhabitant.InhabitantOverview;
import fr.koka.sanctuary.knowledge.BlockKnowledgeService;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.community.*;
import fr.koka.sanctuary.mixin.InhabitantAdvancementAccess;
import java.nio.file.*;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.advancements.*;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.Identifier;
import net.minecraft.stats.Stats;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
public final class Inhabitant271GameTests {
@GameTest public void totalsReadRealPersonalCountersWithoutReplayOrFakePlacement(GameTestHelper h){
var p=Progression003GameTests.player(h);var other=Progression003GameTests.player(h);
p.awardStat(Stats.PLAY_TIME,72000);p.awardStat(Stats.BLOCK_MINED.get(Blocks.STONE),12);p.awardStat(Stats.BLOCK_MINED.get(Blocks.COBBLESTONE),4);
p.awardStat(Stats.ITEM_USED.get(Items.STONE),999);p.awardStat(Stats.MOB_KILLS,3);p.awardStat(Stats.DEATHS,2);
BlockKnowledgeService.recordPlaced(p,Blocks.STONE);BlockKnowledgeService.recordPlaced(p,Blocks.COBBLESTONE);
var map=((InhabitantAdvancementAccess)p.getAdvancements()).sanctuary$progress();int size=map.size();
var first=InhabitantOverview.snapshot(p);var twice=InhabitantOverview.snapshot(p);
h.assertTrue(first.playTicks()==72000&&first.mined()==16&&first.placed()==2&&first.kills()==3&&first.deaths()==2,"Authoritative counters and confirmed placements");
h.assertTrue(first.equals(twice)&&map.size()==size,"Reading never replays totals or creates native advancement progress");
h.assertTrue(InhabitantOverview.snapshot(other).placed()==0&&InhabitantOverview.snapshot(other).mined()==0,"Another player's counters remain isolated");h.succeed();
}
@GameTest public void completedAdvancementsAndOldPurchaseRecordsProduceDatedActivity(GameTestHelper h){
var p=Progression003GameTests.player(h);p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.buy("mining",1).json());
var holder=p.level().getServer().getAdvancements().get(Identifier.withDefaultNamespace("story/mine_stone"));
for(var criterion:holder.value().criteria().keySet())p.getAdvancements().award(holder,criterion);
var first=InhabitantOverview.snapshot(p);var event=first.events().stream().filter(e->e.kind().equals("advancement")&&e.id().endsWith("story/mine_stone")).findFirst().orElseThrow();
h.assertTrue(event.at()>0&&event.text().contains("advancements.story.mine_stone.title"),"Native progress date and translatable title survive the read model");
h.assertTrue(first.events().stream().anyMatch(e->e.id().startsWith("purchase:")&&e.text().contains("sanctuary.progression.mining")),"Existing purchases are available without a new journal");
var requirements=new AdvancementRequirements(List.of(List.of("early","optional"),List.of("required")));var progress=new AdvancementProgress();progress.update(requirements);
progress.grantProgress("early");progress.grantProgress("required");var completed=InhabitantOverview.completion(requirements,progress);progress.grantProgress("optional");
h.assertTrue(completed.equals(InhabitantOverview.completion(requirements,progress)),"A later optional criterion never moves the original completion date");
h.assertTrue(InhabitantOverview.withPosts(first,List.of(),false).events().equals(first.events()),"Community outage keeps native activity available");h.succeed();
}
@GameTest public void personalPublicationsKeepVisibilityAndReadOnlyPersistence(GameTestHelper h)throws Exception{
var p=Progression003GameTests.player(h);var actor=new Community.Actor(p.getStringUUID(),"Auteur",false);var other=new Community.Actor(UUID.randomUUID().toString(),"Voisin",false);
var path=Files.createTempDirectory("inhabitant271-community-").resolve("community.json");var server=UUID.randomUUID().toString();var store=new FileCommunityStore(path,server);
var mine=store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"","notice","info","Mon atelier","Corps qui ne doit pas être envoyé dans le résumé",0));
var theirs=store.change(other,new Community.Change("create",UUID.randomUUID().toString(),"","notice","info","Autre atelier","Texte",0));
byte[] before=Files.readAllBytes(path);var snapshot=InhabitantOverview.withPosts(InhabitantOverview.snapshot(p),store.activity(actor),true);
h.assertTrue(Arrays.equals(before,Files.readAllBytes(path)),"Overview leaves the existing file byte-identical");
h.assertTrue(snapshot.events().stream().anyMatch(e->e.id().equals("post:"+mine.id())&&e.text().contains("Mon atelier")&&!e.text().contains("Corps")),"Only title and original date enter the activity summary");
h.assertTrue(InhabitantOverview.withPosts(snapshot,List.of(theirs),true).events().equals(snapshot.events()),"Unrelated authors cannot enter a personal read model");
var reloaded=new FileCommunityStore(path,server);h.assertTrue(reloaded.activity(actor).equals(store.activity(actor)),"Historical publications are available after a store restart");
var deduplicated=InhabitantOverview.withPosts(snapshot,store.activity(actor),true);h.assertTrue(deduplicated.events().equals(snapshot.events()),"Refresh does not duplicate publications");
Files.delete(path);Files.delete(path.getParent());h.succeed();
}
}
@@ -13,7 +13,7 @@ import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
public final class Insect245GameTests {
@GameTest public void seasonalHoursPeaksAndMidnight(GameTestHelper h) {
@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");
@@ -25,13 +25,13 @@ public final class Insect245GameTests {
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 public void netRecipeUsesTwoSticksAndThreeStrings(GameTestHelper h) {
@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 public void surfacesFlowersWaterAndRain(GameTestHelper h) {
@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)
@@ -49,7 +49,7 @@ public final class Insect245GameTests {
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 public void trunkFacesRemainAttachedAndPalmsUseJungle(GameTestHelper h) {
@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) {
@@ -63,7 +63,7 @@ public final class Insect245GameTests {
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 public void sneakScareDamageAndEscape(GameTestHelper h) {
@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");
@@ -74,7 +74,7 @@ public final class Insect245GameTests {
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 public void netCapturesOnceAndRejectsWallsRangeAndFleeing(GameTestHelper h) {
@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");
@@ -88,7 +88,7 @@ public final class Insect245GameTests {
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 public void standingNetChoosesNearestVisibleGroundInsect(GameTestHelper h) {
@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));
@@ -96,7 +96,7 @@ public final class Insect245GameTests {
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 public void fleaStaysOnHostAndFlatSpeciesNeverHop(GameTestHelper h) {
@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);
@@ -115,7 +115,7 @@ public final class Insect245GameTests {
}
h.succeed();
}
@GameTest public void snailContactsFlowerAndSnowballRollsWithBeetle(GameTestHelper h) {
@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);
@@ -126,7 +126,7 @@ public final class Insect245GameTests {
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 public void dragonflyAcceleratesTowardsWaypoint(GameTestHelper h) {
@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());}
@@ -134,7 +134,7 @@ public final class Insect245GameTests {
h.assertTrue(origin.distanceToSqr(bug.position())<4 && !bug.isRemoved(),"Waypoint flight remains bounded to its habitat");
h.succeed();
}
@GameTest public void grasshoppersTravelInArcsAndRestAfterLanding(GameTestHelper h) {
@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;
@@ -151,7 +151,7 @@ public final class Insect245GameTests {
}
h.succeed();
}
@GameTest public void grasshoppersFleeWithRapidBondsAndCanTurnMidair(GameTestHelper h) {
@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();
@@ -172,7 +172,7 @@ public final class Insect245GameTests {
}
h.succeed();
}
@GameTest public void grasshoppersRejectWallsAndWetLandings(GameTestHelper h) {
@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());
@@ -185,9 +185,102 @@ public final class Insect245GameTests {
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.isRemoved() && dry.position().equals(origin),"A terrestrial insect refuses a landing in water");dry.discard();h.succeed();
h.assertTrue(dry.position().equals(origin),"An isolated grass cell does not permit a jump into surrounding water");dry.discard();h.succeed();
}
@GameTest public void fullInventoryPreservesCapturedItemAndSpectatorCannotCatch(GameTestHelper h) {
@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));
@@ -199,7 +292,7 @@ public final class Insect245GameTests {
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 public void spawningIsBoundedTransientAndDoesNotLoadChunks(GameTestHelper h) {
@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");
@@ -0,0 +1,33 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.property.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.gems.Gems;
import fr.koka.sanctuary.familiar.FamiliarExperience;
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;
public final class Menu275GameTests {
private static ServerPlayer admitted(GameTestHelper h)throws Exception{
var p=Progression003GameTests.player(h);var identities=ProgressionService.session(h.getLevel().getServer()).inhabitants;
identities.create(p.getUUID(),"AtlasGate","",identities.available().getFirst());identities.arrive(p.getUUID());p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);return p;
}
@GameTest public void propertyActionsRequirePurchasedActiveAtlasAndKeepPaidRights(GameTestHelper h)throws Exception{
var p=admitted(h);var other=admitted(h);p.setPos(8000,80,8000);p.level().getChunk(500,500);
var store=ChunkPropertyService.store(p.level().getServer());var key=new ChunkClaims.Key(500,500);long revision=store.revision();
h.assertTrue(!ChunkPropertyService.eligible(p)&&!ChunkPropertyService.selectable(p,key),"Admission alone never unlocks properties or a local map");
h.assertTrue(ChunkPropertyService.act(p,"claim",key,revision).equals("atlas_required")&&store.revision()==revision&&store.count(p.getUUID())==0,"A forged claim action cannot bypass the Atlas purchase");
p.setExperienceLevels(20);h.assertTrue(ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()),"Buy the actual Atlas aptitude");FamiliarExperience.setPlayerXp(p,0);
fr.koka.demeure.DemeureService.record(p,p.blockPosition(),1);
h.assertTrue(ChunkPropertyService.eligible(p)&&ChunkPropertyService.act(p,"claim",key,store.revision()).equals("claimed"),"Atlas unlocks normal property actions");
p.getInventory().setItem(9,new ItemStack(Gems.SAPPHIRE.gem(),8));p.getInventory().setItem(10,new ItemStack(Gems.SAPPHIRE.gem(),8));
h.assertTrue(ChunkPropertyService.act(p,"protect",key,store.revision()).equals("protected"),"Paid protection is created normally");long until=store.get(key).until();
h.assertTrue(AptitudeService.set(p,new AptitudeService.Set("atlas",false,AptitudeService.preferences(p).disabledMask())),"Disable the actual Atlas preference");
h.assertTrue(!ChunkPropertyService.eligible(p)&&ChunkPropertyService.act(p,"protect",key,store.revision()).equals("atlas_required"),"Disabled Atlas cannot renew protection");
h.assertTrue(ChunkPropertyService.sapphires(p)==8&&store.get(key).until()==until&&store.count(p.getUUID())==1,"Refusal spends nothing and preserves acquired land and coverage");
h.assertTrue(ChunkPropertyService.multiplier(p,p.level(),p.blockPosition())==2&&ChunkPropertyService.multiplier(other,other.level(),p.blockPosition())==.5f,"Protection still applies to owners and other inhabitants without an Atlas");
h.assertTrue(AptitudeService.set(p,new AptitudeService.Set("atlas",true,AptitudeService.preferences(p).disabledMask()))&&ChunkPropertyService.eligible(p),"Enabling Atlas restores management access");h.succeed();
}
}
@@ -0,0 +1,78 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.property.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.familiar.FamiliarExperience;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
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.context.BlockPlaceContext;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
public final class Property270GameTests {
private static ServerPlayer admitted(GameTestHelper h)throws Exception{
var p=Progression003GameTests.player(h);var s=ProgressionService.session(h.getLevel().getServer());
s.inhabitants.create(p.getUUID(),"PropertyTest","",s.inhabitants.available().getFirst());s.inhabitants.arrive(p.getUUID());
return p;
}
@GameTest public void nativeBreakPlacementInteractionsAndBulkRights(GameTestHelper h)throws Exception{
var level=h.getLevel();var owner=admitted(h);var other=admitted(h);var pos=h.absolutePos(new BlockPos(1024,20,1024));
var store=PropertyService.store(level.getServer());
h.assertTrue(store.claim(owner.getUUID(),new PropertyClaim(pos.getX(),pos.getY(),pos.getZ(),0)),"Reserve test volume");
other.setPos(pos.getX()+.5,pos.getY(),pos.getZ()+2.5);
level.setBlockAndUpdate(pos,Blocks.STONE.defaultBlockState());
h.assertTrue(!level.mayInteract(other,pos)&&level.mayInteract(owner,pos),"Native and bulk permission path checks UUID");
h.assertTrue(!other.gameMode.destroyBlock(pos)&&level.getBlockState(pos).is(Blocks.STONE),"Native mining cannot remove another inhabitant's block");
other.setGameMode(GameType.CREATIVE);
h.assertTrue(!other.gameMode.destroyBlock(pos),"Creative does not bypass ownership");
var hit=new BlockHitResult(Vec3.atCenterOf(pos),Direction.UP,pos,false);
h.assertTrue(UseBlockCallback.EVENT.invoker().interact(other,level,InteractionHand.MAIN_HAND,hit)==InteractionResult.FAIL,"Interaction cannot open a protected block or tool callback");
h.assertTrue(!other.mayUseItemAt(pos,Direction.UP,new ItemStack(Items.WATER_BUCKET)),"Native item permission refuses liquid changes");
other.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.COBBLESTONE,4));
var result=((BlockItem)Items.COBBLESTONE).place(new BlockPlaceContext(other,InteractionHand.MAIN_HAND,other.getMainHandItem(),hit));
h.assertTrue(result==InteractionResult.FAIL&&other.getMainHandItem().getCount()==4&&level.getBlockState(pos.above()).isAir(),"Direct BlockItem call refuses protected placement without consuming an item");
var above=pos.above(9);h.assertTrue(level.mayInteract(other,above),"Same chunk above finite height is free");
var outside=pos.offset(9,0,0);level.setBlockAndUpdate(outside,Blocks.STONE.defaultBlockState());
h.assertTrue(other.gameMode.destroyBlock(outside),"An unclaimed neighbouring block is still mineable");
level.setBlockAndUpdate(outside,Blocks.STONE.defaultBlockState());
other.setPos(outside.getX()+.5,outside.getY(),outside.getZ()+2.5);
var outsideHit=new BlockHitResult(Vec3.atCenterOf(outside),Direction.UP,outside,false);
h.assertTrue(((BlockItem)Items.COBBLESTONE).place(new BlockPlaceContext(other,InteractionHand.MAIN_HAND,other.getMainHandItem(),outsideHit))!=InteractionResult.FAIL
&&level.getBlockState(outside.above()).is(Blocks.COBBLESTONE),"Unclaimed land remains buildable");
var lower=pos.below(9);level.setBlockAndUpdate(lower.below(),Blocks.STONE.defaultBlockState());
level.setBlock(lower,Blocks.OAK_DOOR.defaultBlockState(),2);
level.setBlock(lower.above(),Blocks.OAK_DOOR.defaultBlockState().setValue(net.minecraft.world.level.block.DoorBlock.HALF,net.minecraft.world.level.block.state.properties.DoubleBlockHalf.UPPER),2);
h.assertTrue(!other.gameMode.destroyBlock(lower),"Breaking a free lower door cannot remove its protected upper half");
h.assertTrue(owner.gameMode.destroyBlock(pos)&&level.getBlockState(pos).isAir(),"Owner can mine normally");h.succeed();
}
@GameTest public void freeEntitlementXpPurchaseStaleRequestsAndNativeReceipt(GameTestHelper h)throws Exception{
var p=admitted(h);var server=p.level().getServer();var store=PropertyService.store(server);
var spawn=server.getRespawnData().pos();p.setPos(spawn.getX()+512,80,spawn.getZ()+512);
var guest=Progression003GameTests.player(h);h.assertTrue(!PropertyService.eligible(guest),"Hello World is required");
h.assertTrue(PropertyService.act(p,"claim",-1).equals("claimed")&&FamiliarExperience.playerXp(p)==0,"Free entitlement works without XP or an item");
h.assertTrue(PropertyService.act(p,"claim",-1).equals("changed"),"Repeated creation is refused");
h.assertTrue(PropertyService.act(p,"expand",0).equals("xp")&&store.get(p.getUUID()).tier()==0,"Insufficient XP does not reserve an extension");
FamiliarExperience.setPlayerXp(p,350);
h.assertTrue(PropertyService.act(p,"expand",0).equals("expanded"),"Native player checkpoint completes purchase");
h.assertTrue(FamiliarExperience.playerXp(p)==250&&store.get(p.getUUID()).tier()==1&&store.pending(p.getUUID())==null,"Exactly 100 points spent and no unresolved transaction");
String receipt=p.getAttachedOrThrow(PropertyService.RECEIPT);
h.assertTrue(!receipt.isBlank()&&PropertyService.act(p,"expand",0).equals("changed")&&FamiliarExperience.playerXp(p)==250,"A duplicate stale purchase never spends again");
PropertyService.recover(p);h.assertTrue(FamiliarExperience.playerXp(p)==250,"Repeated recovery does not debit again");
var reloaded=new PropertyStore(server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("sanctuary/properties-v1.json"),server.overworld().getSeed());
h.assertTrue(reloaded.get(p.getUUID()).equals(store.get(p.getUUID())),"Committed property survives disk reload");
for(String alias:java.util.List.of("property","propriete","propriété"))h.assertTrue(server.getCommands().getDispatcher().getRoot().getChild("sanctuary").getChild(alias).canUse(p.createCommandSourceStack()),"Property commands need no operator");
h.succeed();
}
@GameTest public void spawnDimensionAndHeightCannotBeClaimed(GameTestHelper h)throws Exception{
var p=admitted(h);var spawn=p.level().getServer().getRespawnData().pos();p.setPos(spawn.getX(),spawn.getY(),spawn.getZ());
h.assertTrue(PropertyService.act(p,"claim",-1).equals("spawn"),"Shared arrival volume remains unclaimed");
p.setPos(spawn.getX()+1024,p.level().getMinY(),spawn.getZ()+1024);
h.assertTrue(PropertyService.act(p,"claim",-1).equals("bounds"),"Entire cube must fit build height");h.succeed();
}
}
@@ -0,0 +1,98 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.providence.*;
import fr.koka.sanctuary.weather.*;
import java.nio.file.*;
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.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.*;
import net.minecraft.util.ProblemReporter;
public final class Providence265GameTests {
@GameTest(maxTicks=200) public void commandsWeatherDeathSafetyAndDurability(GameTestHelper h) throws Exception {
var server=h.getLevel().getServer();var level=h.getLevel();configured(server);
var commands=server.getCommands().getDispatcher();var p=player(h);var traveler=player(h);
var unsafe=player(h);var changed=player(h);var other=player(h);var unknown=player(h);
var recovery=player(h);var disconnected=player(h);
var historyPath=server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-providence.json");
var death=traveler.blockPosition().offset(5,0,0);var distant=new BlockPos(30000000,-500,30000000);
for(int dx=-1;dx<=1;dx++)for(int dz=-1;dz<=1;dz++) {
level.setBlockAndUpdate(death.offset(dx,-1,dz),Blocks.STONE.defaultBlockState());
for(int dy=0;dy<=2;dy++)level.setBlockAndUpdate(death.offset(dx,dy,dz),Blocks.AIR.defaultBlockState());
}
traveler.setLastDeathLocation(Optional.of(GlobalPos.of(level.dimension(),death)));
changed.setLastDeathLocation(traveler.getLastDeathLocation());
unsafe.setLastDeathLocation(Optional.of(GlobalPos.of(level.dimension(),distant)));
var output=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,level.registryAccess());traveler.saveWithoutId(output);
var loaded=player(h);loaded.load(TagValueInput.create(ProblemReporter.DISCARDING,level.registryAccess(),output.buildResult()));
h.assertTrue(loaded.getLastDeathLocation().equals(traveler.getLastDeathLocation()),"Last death uses native saved player contract");
h.assertTrue(commands.getRoot().getChild("pray").canUse(p.createCommandSourceStack()),"Prayer needs no operator");
h.assertTrue(!commands.getRoot().getChild("providence").getChild("reload").canUse(p.createCommandSourceStack()),"Reload is operator-only");
rain(server);int initialEntries=new PrayerHistory(historyPath).state().entries().size();
h.assertTrue(commands.execute("pray Ô seigneur, j'en ai marre de cet orage, faites revenir le soleil !",p.createCommandSourceStack())==1,"Actual free-text command accepts a waiting prayer");
h.assertTrue(commands.execute("pray ramenez moi a ma tombe",p.createCommandSourceStack())==0,"A second prayer cannot replace or rush the first");
h.assertTrue(level.getWeatherData().isRaining(),"Accepting a prayer does not execute its miracle immediately");
configured(server); // A valid reload preserves the original waiting request and its deadline.
h.startSequence().thenIdle(10).thenExecute(checked(() -> {
h.assertTrue(level.getWeatherData().isRaining() && new PrayerHistory(historyPath).state().entries().size()==initialEntries,"Halfway through the delay there is no effect or final decision");
})).thenIdle(12).thenExecute(checked(() -> {
h.assertTrue(!level.getWeatherData().isRaining() && !level.getWeatherData().isThundering(),"Delayed weather prayer clears native rain and thunder");
if(WeatherService.active(level))h.assertTrue(WeatherService.session(server).state().weather()==DailyWeather.Kind.CLEAR,"Daily forecast changed as well");
var history=new PrayerHistory(historyPath);
h.assertTrue(history.state().entries().stream().filter(e->e.player().equals(p.getUUID())).count()==1 && decision(historyPath,p).executed(),"One original request executes and is persisted once after reload");
h.assertTrue(commands.execute("providence readable true",p.createCommandSourceStack())==1,"Readability control remains immediate");
configured(server);h.assertTrue(new PrayerHistory(historyPath).readable(p.getUUID()),"Readability persists across reload");
var path=ProvidenceService.CONFIG.resolve("miracles.json");var valid=Files.readAllBytes(path);
try {
Files.writeString(path,"{invalid-config");
h.assertTrue(commands.execute("providence reload",server.createCommandSourceStack())==0,"Invalid JSON refuses reload");
h.assertTrue(commands.execute("providence readable false",p.createCommandSourceStack())==1,"Previous valid session stays usable");
} finally {Files.write(path,valid);}
rain(server);traveler.fallDistance=50;
for(var request:Map.of(other,"arrêtez la pluie",unknown,"J'aime cette pluie, ne l'arrêtez pas",
traveler,"Pitié ramenez moi à l'endroit où je suis mort",unsafe,"TP moi a ma tombe stp",
changed,"ramenez moi a ma tombe",recovery,"Seigneur rendez moi mon stuff je suis mort",
disconnected,"bonjour").entrySet())
h.assertTrue(commands.execute("pray "+request.getValue(),request.getKey().createCommandSourceStack())==1,"Each player's request waits independently");
h.assertTrue(!traveler.blockPosition().equals(death),"Return to death also waits before teleporting");
changed.setLastDeathLocation(Optional.of(GlobalPos.of(level.dimension(),distant)));
disconnected.disconnect();
})).thenIdle(22).thenExecute(checked(() -> {
h.assertTrue(decision(historyPath,other).decision().equals("COOLDOWN"),"Global miracle cooldown still applies at delayed resolution");
h.assertTrue(decision(historyPath,unknown).decision().equals("INCOMPREHENSIBLE"),"Negated prayer is refused after its wait");
h.assertTrue(traveler.blockPosition().equals(death) && traveler.fallDistance==0,"Delayed native teleport arrives safely and resets fall distance");
h.assertTrue(decision(historyPath,unsafe).decision().equals("INELIGIBLE"),"Unloaded/out-of-border death refuses without generating chunks");
h.assertTrue(decision(historyPath,changed).decision().equals("INELIGIBLE") && !changed.blockPosition().equals(death),"Destination is validated at resolution, not submission");
h.assertTrue(decision(historyPath,recovery).decision().equals("INELIGIBLE"),"Recovery without an available death grave is refused");
h.assertTrue(new PrayerHistory(historyPath).state().entries().stream().noneMatch(e -> e.player().equals(disconnected.getUUID())),"Disconnected player receives no decision or effect");
unsafe.setLastDeathLocation(Optional.of(GlobalPos.of(net.minecraft.world.level.Level.NETHER,death)));
h.assertTrue(!MiracleValidator.validate(unsafe,MiracleRegistry.Id.RETURN_TO_DEATH).conditions().get("same_dimension"),"Cross-dimension return is refused");
level.setBlockAndUpdate(death,Blocks.LAVA.defaultBlockState());h.assertTrue(!MiracleValidator.safe(traveler,death),"Lava is unsafe");
level.setBlockAndUpdate(death,Blocks.AIR.defaultBlockState());level.setBlockAndUpdate(death.above(),Blocks.STONE.defaultBlockState());
h.assertTrue(!MiracleValidator.safe(traveler,death),"Low ceiling rejects standing collision box");
traveler.setPose(net.minecraft.world.entity.Pose.SWIMMING);h.assertTrue(!MiracleValidator.safe(traveler,death),"Crawling cannot bypass standing-height safety");traveler.setPose(net.minecraft.world.entity.Pose.STANDING);
level.setBlockAndUpdate(death.above(),Blocks.AIR.defaultBlockState());level.setBlockAndUpdate(death.east(),Blocks.CACTUS.defaultBlockState());
h.assertTrue(!MiracleValidator.safe(traveler,death),"Adjacent cactus is unsafe");
var mortal=player(h);mortal.setHealth(0);mortal.die(mortal.damageSources().genericKill());
h.assertTrue(mortal.getLastDeathLocation().isPresent() && mortal.getLastDeathLocation().get().pos().equals(mortal.blockPosition()),"Native death records the position Providence reads");
configured(server);
System.out.println("PROVIDENCE267_SERVER_PASS: delayed effects and verdicts, no early execution, duplicate suppression, reload, changed destination, disconnect, safe teleport and existing cooldowns");
})).thenSucceed();
}
private static void configured(MinecraftServer server)throws Exception {try(var config=new Providence265TestConfig(1)){ProvidenceService.reload(server);}}
private static void rain(MinecraftServer server) throws Exception {
if(WeatherService.active(server.overworld()))server.getCommands().getDispatcher().execute("sanctuary weather rain",server.createCommandSourceStack());
else {var weather=server.overworld().getWeatherData();weather.setRainTime(10000);weather.setThunderTime(10000);weather.setRaining(true);weather.setThundering(true);}
}
private static PrayerHistory.Entry decision(Path path,ServerPlayer player)throws Exception {
return new PrayerHistory(path).state().entries().stream().filter(e -> e.player().equals(player.getUUID())).reduce((a,b)->b).orElseThrow();
}
@FunctionalInterface private interface Checked {void run()throws Exception;}
private static Runnable checked(Checked action) {return () -> {try {action.run();}catch(Exception e){throw new RuntimeException(e);}};}
private static ServerPlayer player(GameTestHelper h) {var p=Progression003GameTests.player(h);p.setUUID(UUID.randomUUID());return p;}
}
@@ -0,0 +1,36 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.*;
import fr.koka.sanctuary.providence.*;
import java.nio.file.*;
/** Explicit disposable test launch only. Production defaults are never changed by the feature. */
public final class Providence265TestConfig implements AutoCloseable {
private final Path path=ProvidenceService.CONFIG.resolve("miracles.json");
private final byte[] previous=Files.exists(path)?Files.readAllBytes(path):null;
private final Path responsePath=ProvidenceService.CONFIG.resolve("responses.json");
private final byte[] previousResponses=Files.exists(responsePath)?Files.readAllBytes(responsePath):null;
public Providence265TestConfig() throws Exception {
this(MiracleRegistry.DEFAULT_RESPONSE_DELAY_SECONDS);
}
public Providence265TestConfig(int responseDelaySeconds) throws Exception {
MiracleRegistry.load(ProvidenceService.CONFIG);
final JsonObject json;
try(var stream=Providence265TestConfig.class.getResourceAsStream("/providence/miracles.json")) {
json=JsonParser.parseString(new String(stream.readAllBytes(),java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject();
}
json.addProperty("responseDelaySeconds",responseDelaySeconds);
for (var element:json.getAsJsonArray("miracles")) {
var o=element.getAsJsonObject(); o.addProperty("probability",1);
}
Files.writeString(path,new Gson().toJson(json));
ProvidenceResponses.load(ProvidenceService.CONFIG);
var responses=JsonParser.parseString(Files.readString(responsePath)).getAsJsonObject();
for(String key:java.util.List.of("deferredChance","alternativeChance","silenceChance"))responses.addProperty(key,0);
Files.writeString(responsePath,responses.toString());
}
public void close() throws Exception {
if (previous==null) Files.deleteIfExists(path); else Files.write(path,previous);
if (previousResponses==null) Files.deleteIfExists(responsePath); else Files.write(responsePath,previousResponses);
}
}
@@ -0,0 +1,154 @@
package fr.koka.sanctuary.gametest;
import com.mojang.authlib.GameProfile;
import fr.koka.sanctuary.grave.*;
import fr.koka.sanctuary.providence.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.weather.*;
import java.nio.file.*;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.network.*;
import net.minecraft.network.protocol.PacketFlow;
import net.minecraft.server.level.*;
import net.minecraft.server.network.*;
import net.minecraft.stats.Stats;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.SkullBlockEntity;
import net.minecraft.world.level.gamerules.GameRules;
import net.minecraft.world.level.storage.*;
public final class Providence268GameTests {
@GameTest public void healthFoodRepairAndSafeSpawn(GameTestHelper h)throws Exception {
var p=player(h,"Aid268");var server=p.level().getServer();var registry=registry();
ProgressEffects.apply(p,ProgressRecord.INITIAL);p.setHealth(1);p.getFoodData().setFoodLevel(0);
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.HEAL))&&p.getHealth()==6,"Healing respects the acquired six-point health cap");
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.FEED))&&p.getFoodData().getFoodLevel()==6,"Food respects Sanctuary hunger capacity");
var tool=new ItemStack(Items.DIAMOND_PICKAXE);tool.setDamageValue(77);p.setItemInHand(InteractionHand.MAIN_HAND,tool);
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.REPAIR_ITEM))&&p.getMainHandItem().getDamageValue()==0,"Held item is repaired without replacement");
h.assertTrue(!MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.REPAIR_ITEM)),"An undamaged held item is ineligible");
var before=server.getRespawnData();var spawn=p.blockPosition().offset(7,0,0);platform(p.level(),spawn);
try {
server.setRespawnData(new LevelData.RespawnData(GlobalPos.of(p.level().dimension(),spawn),0,0));p.fallDistance=50;
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.RESCUE_PLAYER))&&p.blockPosition().equals(spawn)&&p.fallDistance==0,"Rescue uses a safe server spawn, not a personal bed");
server.setRespawnData(new LevelData.RespawnData(GlobalPos.of(p.level().dimension(),new BlockPos(30000000,-500,30000000)),0,0));
h.assertTrue(!MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.RESCUE_PLAYER)),"Unsafe or unloaded spawn is never generated by rescue");
}finally{server.setRespawnData(before);}
h.succeed();
}
@GameTest public void weatherAndAllConnectedTargetsIgnorePvpGate(GameTestHelper h)throws Exception {
var p=player(h,"Weather268");var target=player(h,"Victim268");var neighbour=player(h,"Nearby268");var registry=registry();var server=p.level().getServer();
var weather=p.level().getWeatherData();var previous=WeatherService.session(server).state().weather();boolean pvp=server.getGlobalGameRules().get(GameRules.PVP);
try {
server.getGlobalGameRules().set(GameRules.PVP,false,server);
WeatherService.providenceClear(p.level(),600);
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.RAIN))&&weather.isRaining(),"Rain prayer updates the native weather");
h.assertTrue(WeatherService.session(server).state().weather()==DailyWeather.Kind.RAIN,"Rain survives the daily weather controller");
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.CLEAR_WEATHER))&&!weather.isRaining(),"Sun prayer still clears it");
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.LIGHTNING),target)&&target.getHealth()<20,"Lightning strikes a connected target even with PvP disabled");
h.assertTrue(neighbour.getHealth()==20,"Only the named target is damaged");
h.assertTrue(target.level().getEntitiesOfClass(LightningBolt.class,target.getBoundingBox().inflate(3)).isEmpty(),"Lightning stays a client visual; no server bolt can ignite fire, power rods or change copper");
target.removeAllEffects();
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.CURSE),target)&&!target.getActiveEffects().isEmpty(),"Curse applies a native negative effect");
target.removeAllEffects();target.setHealth(20);target.setInvulnerableTime(0);target.damageCooldownTime=0;target.getFoodData().setFoodLevel(20);
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.VENGEANCE),target),"Vengeance selects an available punishment");
h.assertTrue(target.getHealth()<20||target.getFoodData().getFoodLevel()<20||!target.getActiveEffects().isEmpty(),"Punishment has an observable effect");
var destination=target.blockPosition().offset(5,0,0);platform(target.level(),destination);target.setPos(destination.getX()+.5,destination.getY(),destination.getZ()+.5);
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.TELEPORT_PLAYER),target)&&p.distanceToSqr(target)<100,"Teleportation joins the named player at a safe loaded location");
}finally {
server.getGlobalGameRules().set(GameRules.PVP,pvp,server);
server.getCommands().getDispatcher().execute("sanctuary weather "+previous.key(),server.createCommandSourceStack());
}
h.succeed();
}
@GameTest public void theftAndDisarmConserveRealStacks(GameTestHelper h)throws Exception {
var receiver=player(h,"Thief268");var target=player(h,"ItemOwner268");var registry=registry();
ProgressEffects.apply(receiver,ProgressRecord.INITIAL);ProgressEffects.apply(target,ProgressRecord.INITIAL);
h.assertTrue(ProvidenceInventory.slots(receiver).size()==9,"A starting player exposes only the acquired storage row");
target.getInventory().setItem(0,new ItemStack(Items.DIAMOND,3));target.getInventory().setItem(9,new ItemStack(Items.EMERALD,64));
for(int i=0;i<3;i++)h.assertTrue(MiracleExecutor.execute(receiver,registry.definition(MiracleRegistry.Id.STEAL),target),"One actual item is stolen per grant");
h.assertTrue(count(receiver,Items.DIAMOND)==3&&count(target,Items.DIAMOND)==0,"Repeated theft conserves total item count");
h.assertTrue(!MiracleExecutor.execute(receiver,registry.definition(MiracleRegistry.Id.STEAL),target),"Inaccessible locked storage cannot be stolen");
h.assertTrue(!MiracleExecutor.execute(receiver,registry.definition(MiracleRegistry.Id.STEAL),receiver),"Self theft never duplicates items");
for(int slot:ProvidenceInventory.slots(receiver))receiver.getInventory().setItem(slot,new ItemStack(Items.STONE,64));
target.getInventory().setItem(0,new ItemStack(Items.DIAMOND,2));
h.assertTrue(!MiracleExecutor.execute(receiver,registry.definition(MiracleRegistry.Id.STEAL),target)&&count(target,Items.DIAMOND)==2,"Full receiver leaves the source intact");
var sword=new ItemStack(Items.DIAMOND_SWORD);sword.setDamageValue(19);target.setItemInHand(InteractionHand.MAIN_HAND,sword);
h.assertTrue(MiracleExecutor.execute(receiver,registry.definition(MiracleRegistry.Id.DISARM),target)&&target.getMainHandItem().isEmpty(),"Disarm removes the actual held stack");
var drops=target.level().getEntitiesOfClass(ItemEntity.class,target.getBoundingBox().inflate(3),e->ItemStack.matches(e.getItem(),sword));
h.assertTrue(drops.size()==1&&drops.getFirst().getItem().getDamageValue()==19,"Exactly one intact damaged sword is dropped");
h.assertTrue(!MiracleExecutor.execute(receiver,registry.definition(MiracleRegistry.Id.DISARM),target),"Repeated disarm cannot copy the previous weapon");h.succeed();
}
@GameTest public void recoverySealsPlacedCarriedAndFloatingGraves(GameTestHelper h)throws Exception {
var registry=registry();var p=player(h,"Recovery268");ProgressEffects.apply(p,ProgressRecord.INITIAL);var head=deathHead(p,4);var stale=head.copy();var at=p.blockPosition().offset(5,0,0);platform(p.level(),at);
var skull=place(p.level(),at,head);var live=GraveService.container(skull);
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.RECOVER_ITEMS)),"Remaining items move from the actual placed grave");
h.assertTrue(count(p,Items.DIAMOND)==4&&live.isEmpty(),"The shared live grave container is emptied");
h.assertTrue(!MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.RECOVER_ITEMS))&&count(p,Items.DIAMOND)==4,"Repeated recovery never recreates the lost inventory");
p.level().setBlockAndUpdate(at,Blocks.AIR.defaultBlockState());skull=place(p.level(),at,stale);GraveRecovery.stopped(p.level().getServer());
h.assertTrue(GraveService.head(skull).getOrDefault(DataComponents.CONTAINER,ItemContainerContents.EMPTY).nonEmptyItemCopyStream().findAny().isEmpty(),"A restored stale chunk cannot drop the consumed contents");
h.assertTrue(GraveService.container(skull).isEmpty(),"A restored stale chunk cannot reopen consumed contents");
p.getInventory().setItem(8,stale.copy());GraveService.open(p,p.getInventory().getItem(8));p.closeContainer();
h.assertTrue(p.getInventory().getItem(8).getOrDefault(DataComponents.CONTAINER,ItemContainerContents.EMPTY).nonEmptyItemCopyStream().findAny().isEmpty(),"A stale carried copy is also sealed after ledger reload");
var carried=player(h,"Carried268");ProgressEffects.apply(carried,ProgressRecord.INITIAL);carried.getInventory().setItem(0,deathHead(carried,3));
h.assertTrue(MiracleExecutor.execute(carried,registry.definition(MiracleRegistry.Id.RECOVER_ITEMS))&&count(carried,Items.DIAMOND)==3,"Carried grave contents can be recovered");
h.assertTrue(carried.getInventory().getItem(0).getOrDefault(DataComponents.CONTAINER,ItemContainerContents.EMPTY).nonEmptyItemCopyStream().findAny().isEmpty(),"Destination commit never restores the source head snapshot");
var floating=player(h,"Floating268");ProgressEffects.apply(floating,ProgressRecord.INITIAL);var entity=new ItemEntity(floating.level(),floating.getX(),floating.getY()+2,floating.getZ(),deathHead(floating,2));entity.setNoGravity(true);floating.level().addFreshEntity(entity);
h.assertTrue(MiracleExecutor.execute(floating,registry.definition(MiracleRegistry.Id.RECOVER_ITEMS))&&count(floating,Items.DIAMOND)==2,"Floating grave contents transfer without an inventory snapshot");
h.assertTrue(entity.getItem().getOrDefault(DataComponents.CONTAINER,ItemContainerContents.EMPTY).nonEmptyItemCopyStream().findAny().isEmpty(),"Floating source remains empty");
var full=player(h,"Full268");ProgressEffects.apply(full,ProgressRecord.INITIAL);var fullHead=deathHead(full,1);var fullAt=full.blockPosition().offset(-5,0,0);platform(full.level(),fullAt);var fullSkull=place(full.level(),fullAt,fullHead);
for(int slot:ProvidenceInventory.slots(full))full.getInventory().setItem(slot,new ItemStack(Items.STONE,64));
h.assertTrue(!MiracleExecutor.execute(full,registry.definition(MiracleRegistry.Id.RECOVER_ITEMS))&&!GraveService.container(fullSkull).isEmpty(),"Insufficient space refuses without consuming or sealing a grave");
full.getInventory().setItem(0,ItemStack.EMPTY);
h.assertTrue(MiracleExecutor.execute(full,registry.definition(MiracleRegistry.Id.RECOVER_ITEMS))&&count(full,Items.DIAMOND)==1,"Freeing space allows the untouched source to be recovered");
System.out.println("PROVIDENCE268_RECOVERY_PASS: live containers, repeated requests, stale chunk/item copies, durable seal reload, carried/floating graves and full inventory");h.succeed();
}
@GameTest(maxTicks=160) public void namedTargetsAreBoundBeforeTheDelayedDecision(GameTestHelper h)throws Exception {
var server=h.getLevel().getServer();var actor=player(h,"Praying268");var target=player(h,"Target268");var next=player(h,"Target268");var second=player(h,"SecondPrayer268");
server.getPlayerList().getPlayers().add(target);server.getPlayerList().getPlayersByUUID().put(target.getUUID(),target);
configured(server);
var commands=server.getCommands().getDispatcher();
h.assertTrue(commands.execute("pray foudroie @Target268",actor.createCommandSourceStack())==1&&target.getHealth()==20,"Named hostile prayer is accepted without an immediate effect");
h.startSequence().thenIdle(10).thenExecute(()->h.assertTrue(target.getHealth()==20,"Named player still waits for the response deadline"))
.thenIdle(12).thenExecute(()->{
h.assertTrue(target.getHealth()<20,"Delayed prayer strikes the connected exact-name target");
target.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.DIAMOND_SWORD));next.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.IRON_SWORD));
try{configured(server);h.assertTrue(commands.execute("pray desarme @Target268",second.createCommandSourceStack())==1,"Second targeted request waits");}catch(Exception e){throw new RuntimeException(e);}
server.getPlayerList().getPlayers().remove(target);server.getPlayerList().getPlayersByUUID().remove(target.getUUID());
server.getPlayerList().getPlayers().add(next);server.getPlayerList().getPlayersByUUID().put(next.getUUID(),next);
}).thenIdle(22).thenExecute(()->{
try{
h.assertTrue(!target.getMainHandItem().isEmpty()&&!next.getMainHandItem().isEmpty(),"A new player with the same name is never substituted for the bound UUID");
var path=server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-providence.json");
var entry=new PrayerHistory(path).state().entries().stream().filter(e->e.player().equals(second.getUUID())).findFirst().orElseThrow();
h.assertTrue(entry.decision().equals("INELIGIBLE")&&!entry.conditions().get("target_online"),"Absent target is recorded as refused");
server.getPlayerList().getPlayers().remove(next);server.getPlayerList().getPlayersByUUID().remove(next.getUUID());configured(server);
System.out.println("PROVIDENCE268_TARGETS_PASS: exact connected target, delayed execution and no name-to-UUID substitution");
}catch(Exception e){throw new RuntimeException(e);}
}).thenSucceed();
}
private static void configured(net.minecraft.server.MinecraftServer server)throws Exception {try(var config=new Providence265TestConfig(1)){ProvidenceService.reload(server);}}
private static MiracleRegistry registry()throws Exception {try(var config=new Providence265TestConfig(1)){return MiracleRegistry.load(ProvidenceService.CONFIG);}}
private static ItemStack deathHead(ServerPlayer p,int diamonds) {
var head=GraveService.head(p,List.of(new ItemStack(Items.DIAMOND,diamonds)));
p.getStats().setValue(p,Stats.CUSTOM.get(Stats.DEATHS),1);p.setLastDeathLocation(Optional.of(GlobalPos.of(p.level().dimension(),p.blockPosition())));return head;
}
private static SkullBlockEntity place(ServerLevel level,BlockPos at,ItemStack head) {
level.setBlockAndUpdate(at,Blocks.PLAYER_HEAD.defaultBlockState());var skull=(SkullBlockEntity)level.getBlockEntity(at);skull.applyComponentsFromItemStack(head);skull.setChanged();return skull;
}
private static void platform(ServerLevel level,BlockPos center) {for(int dx=-2;dx<=2;dx++)for(int dz=-2;dz<=2;dz++){level.setBlockAndUpdate(center.offset(dx,-1,dz),Blocks.STONE.defaultBlockState());for(int dy=0;dy<3;dy++)level.setBlockAndUpdate(center.offset(dx,dy,dz),Blocks.AIR.defaultBlockState());}}
private static int count(ServerPlayer p,Item item) {return ProvidenceInventory.slots(p).stream().mapToInt(i->{var s=p.getInventory().getItem(i);return s.is(item)?s.getCount():0;}).sum();}
private static ServerPlayer player(GameTestHelper h,String name) {
var profile=new GameProfile(UUID.randomUUID(),name);var p=new ServerPlayer(h.getLevel().getServer(),h.getLevel(),profile,ClientInformation.createDefault());
var connection=new Connection(PacketFlow.SERVERBOUND);new io.netty.channel.embedded.EmbeddedChannel(connection);
p.connection=new ServerGamePacketListenerImpl(h.getLevel().getServer(),connection,p,CommonListenerCookie.createInitial(profile,false));p.setGameMode(net.minecraft.world.level.GameType.SURVIVAL);p.setInvulnerableTime(0);p.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());p.setPos(h.absoluteVec(new net.minecraft.world.phys.Vec3(2,2,2)));return p;
}
}
@@ -0,0 +1,108 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.*;
import com.mojang.authlib.GameProfile;
import fr.koka.sanctuary.providence.*;
import fr.koka.sanctuary.progression.*;
import java.nio.file.*;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.ChatFormatting;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.network.*;
import net.minecraft.network.chat.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.network.protocol.PacketFlow;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.*;
import net.minecraft.server.network.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.storage.LevelResource;
public final class Providence269GameTests {
private record Actor(ServerPlayer player,List<Component> messages) { }
@GameTest public void everyVisibleResponseHasTheSameColourInSgaAndReadable(GameTestHelper h) {
var colours=Map.of("granted",ChatFormatting.GREEN,"deferred",ChatFormatting.YELLOW,"refused",ChatFormatting.RED,"incomprehensible",ChatFormatting.GRAY,"alternative",ChatFormatting.LIGHT_PURPLE);
for(boolean readable:List.of(false,true))for(var e:colours.entrySet()) {
var message=ProvidenceMessages.message(e.getKey(),readable,0);
h.assertTrue(ProvidenceMessages.visible(e.getKey())&&message.getStyle().getColor()!=null&&message.getStyle().getColor().getValue()==TextColor.fromLegacyFormat(e.getValue()).getValue(),"Verdict colour in both reading modes: "+e.getKey());
if(!readable)h.assertTrue(message.getStyle().getFont().toString().contains("providence"),"SGA font is retained alongside colour");
}
h.assertTrue(!ProvidenceMessages.visible("SILENT"),"Silence has no visible message");
h.assertTrue(ProvidenceMessages.message("cooldown",true,0).getStyle().getColor().getValue()==TextColor.RED.getValue(),"Cooldown refusal never promises a deferred miracle");h.succeed();
}
@GameTest(maxTicks=400) public void sixResponsesHaveRealEffectsAndDeferredWorkNeverRetargets(GameTestHelper h)throws Exception {
var server=h.getLevel().getServer();var hero=actor(h,"Deferred269");var caster=actor(h,"Caster269");var victim=actor(h,"Victim269");var replacement=actor(h,"Victim269");
var alternative=actor(h,"Alternative269");var refused=actor(h,"Refused269");var unknown=actor(h,"Unknown269");var silent=actor(h,"Silent269");var cancelled=actor(h,"Cancelled269");var changed=actor(h,"Changed269");
// Earlier Providence tests finish before this case changes the shared response policy.
h.startSequence().thenIdle(120).thenExecute(checked(()->{
configure(server,1,1,0,0);add(server,victim.player());
pray(h,hero,"gueris moi");pray(h,caster,"foudroie @Victim269");
h.assertTrue(hero.player().getHealth()==1&&keys(hero).isEmpty(),"There is no immediate reply or effect");
})).thenIdle(22).thenExecute(checked(()->{
h.assertTrue(hero.player().getHealth()==1&&has(hero,"deferred")&&keys(hero).size()==1,"Differee is announced while healing is still pending");
h.assertTrue(victim.player().getHealth()==1&&has(caster,"deferred"),"Targeted deferred prayer has no early damage");
h.assertTrue(ProvidenceService.pray(hero.player(),"nourris moi")==0,"A later miracle occupies the same per-player queue slot");
remove(server,victim.player());add(server,replacement.player());
var path=ProvidenceService.CONFIG.resolve("responses.json");var valid=Files.readAllBytes(path);
try{Files.writeString(path,"{invalid-response-policy");boolean rejected=false;try{ProvidenceService.reload(server);}catch(Exception expected){rejected=true;}h.assertTrue(rejected,"Invalid response policy refuses reload without replacing the session");}finally{Files.write(path,valid);}
configure(server,1,0,0,0);
})).thenIdle(10).thenExecute(()->h.assertTrue(hero.player().getHealth()==1&&keys(hero).size()==1,"Reload never shortens the deferred effect deadline"))
.thenIdle(35).thenExecute(checked(()->{
h.assertTrue(hero.player().getHealth()==6&&keys(hero).size()==2&&has(hero,"granted"),"Exaucee follows the actual later effect, once");
h.assertTrue(victim.player().getHealth()==1&&replacement.player().getHealth()==1&&has(caster,"ineligible"),"Departed target is refused; same-name replacement never receives the strike");
h.assertTrue(entry(server,hero).executed()&&entries(server,hero).size()==1,"Deferred completion updates its original persistent decision");
remove(server,replacement.player());
configure(server,0,0,1,0);refused.player().getFoodData().setFoodLevel(6);
pray(h,alternative,"gueris moi");pray(h,refused,"gueris moi");pray(h,unknown,"bonjour");
})).thenIdle(22).thenExecute(checked(()->{
h.assertTrue(alternative.player().getHealth()==1&&alternative.player().getFoodData().getFoodLevel()==6&&has(alternative,"alternative"),"Alternative feeds the player instead of healing");
h.assertTrue(entry(server,alternative).decision().equals("ALTERNATIVE")&&entry(server,alternative).conditions().get("alternative_FEED"),"Actual different help is journalled");
h.assertTrue(refused.player().getHealth()==1&&has(refused,"refused")&&!entry(server,refused).executed(),"Refusee has no effect when no alternative is possible");
h.assertTrue(has(unknown,"incomprehensible")&&!entry(server,unknown).executed(),"Incomprise stays a distinct visible response");
configure(server,0,0,0,1);pray(h,silent,"gueris moi");
})).thenIdle(22).thenExecute(checked(()->{
h.assertTrue(keys(silent).isEmpty()&&silent.player().getHealth()==1&&entry(server,silent).decision().equals("SILENT"),"Silence is journalled but sends no visible reply or effect");
configure(server,1,1,0,0);pray(h,cancelled,"gueris moi");pray(h,changed,"gueris moi");
})).thenIdle(22).thenExecute(()->{
h.assertTrue(has(cancelled,"deferred")&&has(changed,"deferred"),"Both later grants are initially deferred");
cancelled.player().disconnect();changed.player().setHealth(6);
}).thenIdle(45).thenExecute(checked(()->{
h.assertTrue(cancelled.player().getHealth()==1&&entry(server,cancelled).decision().equals("CANCELLED")&&keys(cancelled).size()==1,"A disconnected actor never receives its delayed grant");
h.assertTrue(changed.player().getHealth()==6&&entry(server,changed).decision().equals("INELIGIBLE")&&has(changed,"ineligible"),"Changed eligibility is refused at the actual effect time");
h.assertTrue(keys(hero).size()==2,"No timer or reload replays an earlier miracle");ProvidenceService.reload(server);
System.out.println("PROVIDENCE269_SERVER_PASS: six real outcomes, true invisible silence, alternative food, deferred execution once, reload, original entry, no target substitution, cancellation and changed eligibility");
})).thenSucceed();
}
public static void configure(MinecraftServer server,double healChance,double deferred,double alternative,double silence)throws Exception {
configure(server,healChance,deferred,alternative,silence,1,2);
}
public static void configure(MinecraftServer server,double healChance,double deferred,double alternative,double silence,int responseSeconds,int deferredSeconds)throws Exception {
try(var fixture=new Providence265TestConfig(responseSeconds)) {
var path=ProvidenceService.CONFIG.resolve("miracles.json");var miracles=JsonParser.parseString(Files.readString(path)).getAsJsonObject();
for(var e:miracles.getAsJsonArray("miracles"))if(e.getAsJsonObject().get("id").getAsString().equals("HEAL"))e.getAsJsonObject().addProperty("probability",healChance);
Files.writeString(path,miracles.toString());path=ProvidenceService.CONFIG.resolve("responses.json");var responses=JsonParser.parseString(Files.readString(path)).getAsJsonObject();
responses.addProperty("deferredChance",deferred);responses.addProperty("alternativeChance",alternative);responses.addProperty("silenceChance",silence);responses.addProperty("deferredSeconds",deferredSeconds);
Files.writeString(path,responses.toString());ProvidenceService.reload(server);
}
}
private static void pray(GameTestHelper h,Actor actor,String text)throws Exception {h.assertTrue(actor.player().level().getServer().getCommands().getDispatcher().execute("pray "+text,actor.player().createCommandSourceStack())==1,"Actual command admits a waiting prayer");}
private static List<String> keys(Actor actor){return actor.messages().stream().filter(c->c.getContents() instanceof TranslatableContents t&&t.getKey().startsWith("sanctuary.providence.")).map(c->((TranslatableContents)c.getContents()).getKey()).toList();}
private static boolean has(Actor actor,String verdict){return keys(actor).stream().anyMatch(k->k.contains("."+verdict+"."));}
private static List<PrayerHistory.Entry> entries(MinecraftServer server,Actor actor)throws Exception {return new PrayerHistory(server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-providence.json")).state().entries().stream().filter(e->e.player().equals(actor.player().getUUID())).toList();}
private static PrayerHistory.Entry entry(MinecraftServer server,Actor actor)throws Exception{return entries(server,actor).getLast();}
private static void add(MinecraftServer server,ServerPlayer p){server.getPlayerList().getPlayers().add(p);server.getPlayerList().getPlayersByUUID().put(p.getUUID(),p);}
private static void remove(MinecraftServer server,ServerPlayer p){server.getPlayerList().getPlayers().remove(p);server.getPlayerList().getPlayersByUUID().remove(p.getUUID());}
@FunctionalInterface private interface Checked{void run()throws Exception;}
private static Runnable checked(Checked action){return ()->{try{action.run();}catch(RuntimeException e){throw e;}catch(Exception e){throw new RuntimeException(e);}};}
private static Actor actor(GameTestHelper h,String name) {
var messages=new ArrayList<Component>();var profile=new GameProfile(UUID.randomUUID(),name);
var p=new ServerPlayer(h.getLevel().getServer(),h.getLevel(),profile,ClientInformation.createDefault()) {
@Override public void sendSystemMessage(Component message){messages.add(message);super.sendSystemMessage(message);}
};
var connection=new Connection(PacketFlow.SERVERBOUND);new io.netty.channel.embedded.EmbeddedChannel(connection);
p.connection=new ServerGamePacketListenerImpl(h.getLevel().getServer(),connection,p,CommonListenerCookie.createInitial(profile,false));p.setGameMode(GameType.SURVIVAL);
p.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());p.setPos(h.absoluteVec(new net.minecraft.world.phys.Vec3(2,2,2)));
ProgressEffects.apply(p,ProgressRecord.INITIAL);p.setHealth(1);p.getFoodData().setFoodLevel(0);return new Actor(p,messages);
}
}
@@ -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.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-gametest": ["fr.koka.sanctuary.gametest.Menu275GameTests", "fr.koka.sanctuary.gametest.ClaimMap273GameTests", "fr.koka.sanctuary.gametest.ChunkClaims272GameTests", "fr.koka.sanctuary.gametest.Inhabitant271GameTests","fr.koka.sanctuary.gametest.Property270GameTests","fr.koka.sanctuary.gametest.Providence269GameTests","fr.koka.sanctuary.gametest.Providence268GameTests","fr.koka.sanctuary.gametest.Providence265GameTests","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"],

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