Compare commits

...
Author SHA1 Message Date
koka 49feb5a376 Synchronize verified Sanctuary beta.092 sources and release documentation
Build Sanctuary / build (push) Canceled after 0s
2026-09-16 11:41:27 +02:00
koka 85e062ed91 Version Sanctuary resource pack beta.070 2026-09-15 17:24:31 +02:00
644 changed files with 31462 additions and 241 deletions
+22
View File
@@ -1,5 +1,27 @@
# Changelog
## beta.092 — Clic molette des super pistons
- Socles, têtes et tiges renvoient le piston normal ou collant dorigine.
- Pièces mobiles couvertes ; Ctrl + clic molette exclut les données techniques.
- Sélection en survie et création en créatif selon les règles natives.
- [Détails et vérifications](docs/multiblock-pick-beta092.md).
## beta.091 — Interactions avec les animaux embarqués
- Ciblage au clic gauche/droit des mobs assis dans le même bateau.
- Coups, coffres portés et activation des distributeurs depuis les sièges.
- Protection conservée pour les joueurs et animaux portés sur la tête.
- [Détails et vérifications](docs/boat-passengers-beta091.md).
## beta.090 — Minecraft Java 26.3 finale
- Migration des quatre modules vers Minecraft 26.3 et Fabric API 0.160.5+26.3.
- Fork JEI 30.32.0-sanctuary.3, cible et dépendances centralisées.
- Édition beta.090 du pack de textures, format 97.1, images conservées.
- Packs normal et Test reconstruits ; aucun monde personnel migré.
- [Contrat et vérifications](docs/minecraft-26-3-beta090.md).
## beta.061 — continuité musicale et montures colossales
- La musique Minecraft commence au dévoilement du portail, après les lettres. Son flux est conservé
+312 -13
View File
@@ -20,8 +20,8 @@ La [vision complète](docs/vision.md) conserve les intentions ; le
L’économie, les claims et les dimensions décrits dans la vision restent
à implémenter.
Les sources Git sont synchronisées jusqu’à **beta.061**. Le récapitulatif de
[synchronisation](docs/git-sync-beta061.md) précise les vérifications et les
Les sources Git sont synchronisées jusqu’à **beta.092**. Le récapitulatif de
[synchronisation](docs/git-sync-beta092.md) précise les vérifications et les
limites. Les archives bêta mentionnées dans ce document sont des fichiers
locaux ; la dernière release du canal packwiz reste **alpha.30.7**.
@@ -31,6 +31,305 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
l’étude de Xaero et des alternatives libres.
## beta.092 — Clic molette des super pistons
Viser un socle, une tête ou une tige sélectionne le piston normal ou collant
dorigine. Les règles de sélection créatif/survie restent natives.
[Détails et vérifications](docs/multiblock-pick-beta092.md).
[Pack normal](build/Sanctuary-beta.092.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.092.mrpack).
## beta.091 — Animaux embarqués interactifs
Clic gauche et clic droit ciblent les animaux assis dans le même bateau : coups,
coffres et blocs de tête utilisables. Les différents formats de bateaux et radeaux
sont couverts. [Détails et vérifications](docs/boat-passengers-beta091.md).
[Pack normal](build/Sanctuary-beta.091.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.091.mrpack).
## beta.090 — Minecraft 26.3 finale
Le projet cible Minecraft Java **26.3**, avec Fabric API 0.160.5+26.3,
Loader 0.19.5 et Java 25. Sanctuary, Demeure, JEI intégré et le profil Test
partagent la même cible. Le pack de textures reçoit une édition beta.090.
[Contrat et vérifications](docs/minecraft-26-3-beta090.md).
[Pack normal](build/Sanctuary-beta.090.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.090.mrpack).
## beta.089 — collision des super pistons
Correction du crash lorsqu'un joueur touche la tête ou la tige d'un super
piston. Leurs propriétés de suffocation et de visibilité ne lisent plus
l’état réservé au socle. Les machines existantes conservent leur état et leurs textures.
[Détails et vérifications](docs/super-piston-collision-beta089.md).
[Pack normal](build/Sanctuary-beta.089.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.089.mrpack).
## beta.088 — navigation du Fût
La souris reste sur les boutons lorsque l'on change de page ou applique une
recherche dans le Fût. La même correction couvre les onglets du Fourneau ;
les clics envoyés pour une ancienne page restent rejetés.
[Détails et vérifications](docs/fut-navigation-beta088.md).
[Pack normal](build/Sanctuary-beta.088.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.088.mrpack).
## beta.087 — super pistons
La clé dorée réunit neuf pistons identiques en une tête 3 × 3, normale ou
gluante, avec une course de trois blocs et une charge commune de 108 blocs.
Fonctionne dans les six orientations, avec les textures fournies et la plaque
avant de quatre pixels qui sort du socle. [Utilisation et contrat](docs/super-pistons-beta087.md).
[Pack normal](build/Sanctuary-beta.087.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.087.mrpack).
## beta.086 — façade du Fût
Le Fût reçoit ses textures de côtés, dessus et dessous, raccordées sur le
cube complet. Le stockage commun reste inchangé ; les textures du Fourneau
sont également incluses. [Détails et vérifications](docs/fut-textures-beta086.md).
[Pack normal](build/Sanctuary-beta.086.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.086.mrpack).
## beta.085 — façade du Fourneau
Les textures originales forment un seul grand four sur les 27 composants,
avec façade orientée, côtés et dessus raccordés. Les fours indépendants
conservent leur apparence. [Détails et vérifications](docs/fourneau-textures-beta085.md).
[Pack normal](build/Sanctuary-beta.085.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.085.mrpack).
## beta.084 — premiers multiblocs
La clé dorée assemble volontairement les cubes de 27 barils ou de 27 fours.
Le Fût réunit 729 cases avec recherche et pages ; le Fourneau partage le
combustible entre ses cuissons. Le démontage conserve les composants et leur
stock actuel. [Gestes, contrat et vérifications](docs/multiblocs-beta084.md).
[Pack normal](build/Sanctuary-beta.084.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.084.mrpack).
## beta.083 — menus organisés
La pause regroupe six rubriques. Découvertes rassemble les catalogues et
statistiques ; Progression accueille Progrès, Prestige et Historique ;
Factions possède son accès et son journal. L’économie reste différée.
Hello World garde un fond gris uni, qui sassombrit avant lintro complète.
[Organisation et vérifications](docs/menu-organization-beta083.md).
[Pack normal](build/Sanctuary-beta.083.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.083.mrpack).
## beta.082 — commandes météo opérateur
`/weather overcast`, `/weather fog` et `/weather showers` permettent de
choisir les nouvelles météos. Les six profils sont accessibles sous
`/sanctuary weather`, pour la journée Real Time courante.
[Commandes et contrat](docs/weather-commands-beta082.md).
[Pack normal](build/Sanctuary-beta.082.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.082.mrpack).
## beta.081 — pose jusqu’à six blocs
La portée de pose atteint **5,5 blocs au rang 6**, puis **6 blocs au rang 7**.
[Réglage et vérifications](docs/build-reach-beta081.md).
[Pack normal](build/Sanctuary-beta.081.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.081.mrpack).
## beta.080 — portée et vitesse des compétences
Construction fait progresser la portée de pose de **3 à 5,5 blocs**.
Minage fait progresser sa propre portée de **3 à 5,5 blocs** et sa vitesse
de **75 % à 130 %** du vanilla, avec retour aux valeurs normales au rang 3.
[Courbe et validations](docs/build-mining-beta080.md).
[Pack normal](build/Sanctuary-beta.080.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.080.mrpack).
## beta.079 — ordres et défense des familiers
Louverture de H ne bloque plus lordre cliqué juste après. Suivre conserve
la défense immédiate quand le joueur est attaqué, et les ordres affichent
leur confirmation. [Causes reproduites et vérifications](docs/familiar-solo-beta079.md).
[Pack normal](build/Sanctuary-beta.079.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.079.mrpack).
## beta.078 — Combats / Battles
Le menu de duels et darènes sappelle désormais **Combats** en français et
**Battles** en anglais. Les annonces renvoient au menu pause.
[Renommage et vérifications](docs/combat-beta078.md).
Le [cadrage des menus](docs/menu-architecture.md) propose leur future
organisation ; l’économie reste différée.
[Pack normal](build/Sanctuary-beta.078.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.078.mrpack).
## beta.077 — nuages du vide et menus
Une seconde couche à **Y = 0** dessine un grand tourbillon autour de Sanctuary,
deux fois plus large que l’île. Le rendu natif conserve les nuages supérieurs
et sa géométrie reste en cache. Cette livraison regroupe aussi les correctifs
[des menus et du pouvoir utilitaire](docs/menu-navigation-beta076.md).
[Contrat et vérifications](docs/cloud-vortex-beta077.md). Tests natifs, build et
archives vérifiés.
[Pack normal](build/Sanctuary-beta.077.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.077.mrpack).
## beta.076 — navigation des menus
**Duels et arènes** souvre directement dans le menu pause. **Progression**
propose Prestige, Factions et Historique, avec les achats du cycle à nouveau
visibles. Le pouvoir utilitaire du familier affiche sa sélection et son accès.
[Correctifs et vérifications](docs/menu-navigation-beta076.md).
## beta.075 — bibliothèque de plans
Le bouton de recherche Minecraft Schematics est retiré de **K → Bibliothèque**.
[Correctif et vérifications](docs/plans-library-beta075.md). Build et archives vérifiés.
[Pack normal](build/Sanctuary-beta.075.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.075.mrpack).
## beta.074 — familiers autonomes
Les caractères prennent des décisions distinctes : chasse à vue, garde autour
du joueur, riposte et attente des ordres. H bref désigne une cible ou reprend
le suivi ; H maintenu ouvre les ordres. F conserve l’échange des mains.
[Contrat et vérifications](docs/familiar-autonomy-beta074.md).
Parcours natifs, montures, arènes, anciens duels, compilation et archives vérifiés.
[Pack normal](build/Sanctuary-beta.074.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.074.mrpack).
## beta.072 — statues et plans natifs
**K** ouvre loutil natif : transformer un modèle Minecraft en statue de blocs,
importer un `.litematic`, `.schem` v2/v3 ou `.nbt`, déplacer et tourner son plan,
afficher une couche et consulter les blocs restants. En créatif, le serveur
contrôle la zone puis place le plan ; en survie, on construit normalement.
Le lien de recherche web présent dans cette version est retiré en beta.075.
[Contrat, formats et vérifications](docs/statues-plans-beta072.md).
Sans dépendance Litematica/MaLiLib. Les anciennes `.schematic` nécessitent une
conversion externe. Parcours client FR/EN, sept tests serveur, build et
interopérabilité avec Litemapy vérifiés.
[Pack normal](build/Sanctuary-beta.072.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.072.mrpack).
## beta.073 — arènes et paris
**Menu pause → Duels et arènes** (depuis beta.076) : arènes sans plafond d'inscrits, duels entre
joueurs ou familiers, mode mixte, règles visibles et mises en objets. Les
spectateurs consultent les combats annoncés et parient avant leur lancement.
Les joueurs meurent réellement ; les familiers gardent leur K.-O. habituel.
[Parcours, règles et contrat de sauvegarde](docs/arenas-beta073.md).
Client natif, régression des anciens duels, compilation et archives vérifiés.
[Pack normal](build/Sanctuary-beta.073.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.073.mrpack).
## beta.071 — personnalités et traits de monture
Chaque familier possède un caractère stable : agressif, protecteur, passif ou
pacifiste. Les tendances dépendent de lespèce, avec des exceptions ; le
pacifiste obéit aux ordres dattaque. La poursuite est plus vive, les araignées
montées grimpent aux murs, les Nautilus colossaux conservent le Souffle du
Nautilus et le Strider retrouve son appui sur la lave.
[Contrat, audit des montures et tests](docs/familiar-personality-beta071.md).
Client natif, compilation et archives isolées vérifiés ; textures beta.070.
[Pack normal](build/Sanctuary-beta.071.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.071.mrpack).
## beta.070 — resource pack intégré
Les textures fournies sont versionnées et **actives par défaut**, dans les
packs normal et Test. Les nouveaux dessins de faim et de saturation utilisent
les ressources natives ; le pack reste désactivable dans les options.
[Source versionnée](ressources-pack/sanctuary/README.md) ·
[Contrat et vérifications](docs/resourcepack-beta070.md).
Client natif, compilation et égalité des sources/archives vérifiés.
[Pack normal](build/Sanctuary-beta.070.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.070.mrpack) ·
[Resource pack autonome](build/Sanctuary-Resource-Pack-beta.070.zip).
## beta.069 — ordres du familier sur H
**H** ouvre les ordres, **F** échange les objets des deux mains et **G** reste
le pouvoir du familier. Le réglage F hérité des deux dernières versions est
reconverti une seule fois en H. [Contrat et tests](docs/familiar-orders-beta069.md).
Parcours natif, compilation et archives vérifiés.
[Pack normal](build/Sanctuary-beta.069.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.069.mrpack).
## beta.068 — animation du préchargement
Une petite ligne SGA s’éclaire doucement pendant la préparation et le
chargement du terrain. Elle sanime sans ticks de jeu, conserve les vraies
étapes et laisse la progression native des chunks lisible.
[Résultat et vérifications](docs/loading-animation-beta068.md).
Client natif, comparaison de huit captures, compilation et archives vérifiés.
[Pack normal](build/Sanctuary-beta.068.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.068.mrpack).
## beta.067 — touche du familier et dragon volant
F remplace H pour les ordres du familier, sans échanger les objets tenus.
Le dragon regarde dans le sens de son déplacement. Les petits portés planent ;
le grand porté sur la tête monte avec Espace et plane au relâchement.
Le colossal reste une monture pilotable. [Contrat et tests](docs/familiar-controls-beta067.md).
Parcours client, compilation et archives vérifiés.
[Pack normal](build/Sanctuary-beta.067.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.067.mrpack).
## beta.066 — cosmétiques animés sans doublon
Les coffres portés n'affichent plus de coffre fermé sous leur couvercle animé.
La cloche conserve son support et une seule cloche mobile. Le rendu partagé
avec les mobs bénéficie du même correctif ; les textures restent identiques.
[Contrat et tests](docs/head-render-beta066.md). Parcours client natif,
compilation et deux archives vérifiés.
[Pack normal](build/Sanctuary-beta.066.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.066.mrpack).
## beta.065 — collisions des bateaux
Les collisions couvrent toute la coque des huit formats de bateaux et radeaux.
Un virage ne peut plus introduire le bateau dans un mur avant le déplacement.
Le recul reste possible au contact. Les 88 variantes, les formes de blocs
minces et les commandes natives sur leau et en vol sont vérifiés.
[Contrat et tests](docs/boat-collisions-beta065.md). Build et archives vérifiés.
[Pack normal](build/Sanctuary-beta.065.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.065.mrpack).
## beta.064 — cache et étapes réelles de préparation
Les plans de terrain sont conservés sur disque et vérifiés à la réouverture.
Sur le scénario de laudit, la réouverture passe denviron **59 s à 2,1 s** ;
la première création reste autour de **122 s**, introduction complète comprise.
Lattente affiche le travail réellement en cours, en texte Minecraft sur fond
uni, sans portail du Nether. Un cache manquant ou corrompu est recalculé.
[Contrat et vérifications](docs/loading-cache-beta064.md). Build, cache corrompu,
parcours natifs et archives vérifiés.
[Pack normal](build/Sanctuary-beta.064.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.064.mrpack).
## beta.063 — chargement et règle de jeu Sanctuary
La règle **Utiliser Sanctuary**, choisie à la création du monde, permet de jouer
avec Hello World et la progression sur un terrain Minecraft classique ou plat.
Ce choix reste fixé pour la partie. L'audit mesure 122 s à la création d'une
île et 59 s à sa réouverture sur le Mac testé ; les plans recalculés dominent.
[Audit, mesures et prochains tickets](docs/loading-gameplay-beta063.md).
Cette livraison conservait toute l'introduction et proposait l'écran épuré.
Le cache et les étapes réelles sont implémentés dans beta.064 ci-dessus.
Build, parcours natifs et archives vérifiés.
[Pack normal](build/Sanctuary-beta.063.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.063.mrpack).
## beta.062 — soleil et chapeaux des familiers
Les familiers sensibles au soleil brûlent comme leur espèce Minecraft.
Un objet équipé sur leur tête protège des nouvelles inflammations ; cela
fonctionne aussi sur les mobs ordinaires. Les dégâts utilisent la santé et le
K.-O. du familier, sans détruire l'œuf. Maj + clic droit avec un objet équipe
le chapeau, à main vide le récupère. [Contrat et vérifications](docs/familiar-sunlight-beta062.md).
Build, parcours natif et archives vérifiés.
[Pack normal](build/Sanctuary-beta.062.mrpack) ·
[Monde plat rapide](build/Sanctuary-Test-beta.062.mrpack).
## beta.061 — musique darrivée et montures familières
La musique commence au dévoilement du portail et accompagne le flash blanc
@@ -946,7 +1245,7 @@ Le relief de l’île initiale reste celui de lalpha.30.7. Les anciennes
sauvegardes gardent leur contrat et ne reçoivent pas ces îles automatiquement.
Voir [le contrat et les vérifications](docs/expeditions-beta001.md).
Les sources bêta sont disponibles dans Git jusqu’à beta.061 ; la dernière
Les sources bêta sont disponibles dans Git jusqu’à beta.092 ; la dernière
release du canal packwiz reste lalpha.30.7 ci-dessous. Les sections alpha
constituent lhistorique.
@@ -1585,19 +1884,19 @@ deux fois, avec 466 fichiers personnels et réglages suivis conservés.
## Versions et prérequis
Au 8 septembre 2026, la cible disponible est **26.3-pre-2**. Le dépôt ne prétend
pas cibler une version finale 26.3 déjà sortie. Le passage à la version finale
sera une mise à jour explicite, avec vérification des API et des sauvegardes.
Depuis beta.090, la cible est **Minecraft 26.3 finale**, publiée le 15 septembre
2026. Les contrôles et les limites de migration sont documentés dans
[le ticket 26.3](docs/minecraft-26-3-beta090.md).
| Composant | Version fixée |
| --- | --- |
| Minecraft Java | 26.3-pre-2 |
| Minecraft Java | 26.3 |
| Java JDK | 25 |
| Fabric Loader | 0.19.5 |
| Fabric API | 0.160.0+26.3 |
| Fabric API | 0.160.5+26.3 |
| Fabric Loom | 1.17.20 |
| Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 |
| Sanctuary / pack | beta.061 |
| Sanctuary / pack | beta.092 |
Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise
uniquement la bibliothèque standard et repère aussi une installation Python
@@ -1605,9 +1904,9 @@ uniquement la bibliothèque standard et repère aussi une installation Python
par le wrapper. `packwiz` est utile pour modifier les dépendances ou servir le
pack ; il n'est pas nécessaire pour le construire.
Références vérifiées : [Minecraft 26.3-pre-2](https://www.minecraft.net/en-us/article/minecraft-26-3-pre-release-2),
Références vérifiées : [Minecraft 26.3](https://www.minecraft.net/en-us/article/minecraft-java-edition-26-3),
[Fabric](https://fabricmc.net/develop/),
[version Fabric API](https://modrinth.com/mod/fabric-api/version/o9uChmGq).
[version Fabric API](https://modrinth.com/mod/fabric-api/version/oCYG2H4L).
## Construire et lancer
@@ -1626,7 +1925,7 @@ décrits dans [Validation](docs/testing.md).
Résultats :
- `mods/sanctuary/build/libs/sanctuary-beta.061.jar` : mod à installer avec
- `mods/sanctuary/build/libs/sanctuary-beta.092.jar` : mod à installer avec
Fabric API sur la version Minecraft indiquée.
- `build/packwiz/` : pack de développement complet, avec le mod construit et
l'index vérifié. Voir [Installation packwiz](packwiz/README.md).
@@ -1676,7 +1975,7 @@ 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 expérimental de JEI pour 26.3-pre-2 sont imbriqués
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/`.
+22 -1
View File
@@ -15,10 +15,31 @@ 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')
dependsOn(':sanctuary:build', 'verifyPack', 'assembleResourcePack')
commandLine('python3', 'scripts/pack.py', 'assemble')
}
tasks.register('verifyResourcePack', Exec) {
group = 'verification'
description = 'Verify the versioned Sanctuary texture sources and their release hashes.'
commandLine('python3', 'scripts/resource_pack.py')
}
tasks.named('check') { dependsOn('verifyResourcePack') }
tasks.register('assembleResourcePack', Zip) {
group = 'distribution'
description = 'Export the same textures as the built-in resource pack for standalone use.'
dependsOn('verifyResourcePack')
from('ressources-pack/sanctuary') {
include 'assets/**', 'pack.mcmeta', 'pack.png'
exclude '**/.DS_Store'
}
destinationDirectory = layout.buildDirectory
archiveFileName = "Sanctuary-Resource-Pack-${resource_pack_version}.zip"
preserveFileTimestamps = false
reproducibleFileOrder = true
}
tasks.register('assembleTestPack', Exec) {
group = 'distribution'
description = 'Stage the separate flat-world Sanctuary Test profile.'
+172
View File
@@ -0,0 +1,172 @@
# ARENA-01 — duels publics, arènes et paris — beta.073
Branche `codex/familiar-arenas-beta073`. Ticket du 15 septembre 2026.
**Implémenté, client natif, compilation et archives locales vérifiés.**
## Parcours en jeu
**Pause → Progression → Duels et arènes** ouvre la liste publique. Le même
accès figure dans le menu Duel historique. Aucune aptitude de familier ni
prestige n'est requis pour organiser un combat entre joueurs.
1. Créer un **duel** (deux inscrits) ou une **arène** (sans plafond de
participants), à l'emplacement de l'organisateur dans sa dimension.
2. Choisir **familiers seuls**, **joueurs seuls**, ou **joueurs avec familiers**.
Choisir un rayon de 24, 48 ou 96 blocs, une durée de 3, 5 ou 10 minutes,
et l'autorisation de la nourriture, des potions et des perles. Les armes,
boucliers et munitions restent utilisables. Les familiers gardent leurs
statistiques, personnalités, ressources et recharges réelles.
3. Choisir l'objet commun dans l'inventaire débloqué, et une mise d'entrée
de 0 à 64 objets. Zéro signifie gratuit. Par défaut, l'objet est l'émeraude.
Les composants natifs doivent correspondre : un objet renommé ou un
contenant rempli n'est pas équivalent à sa version vierge. La création
inscrit l'organisateur et prélève sa mise après validation serveur.
4. Les autres joueurs consultent les règles, viennent dans le périmètre et
s'inscrivent. Chacun valide **Prêt**. Tout changement de la liste invalide
les validations. L'organisateur lance quand tous sont présents et prêts.
5. **Dix secondes de préparation**, puis chacun pour soi. Les inscriptions
et les paris sont fermés dès le lancement de la préparation.
La liste présente les coordonnées, la dimension, les inscrits, les pots et
l'état du combat. Elle contient aussi les duels historiques à invitation,
qui gardent leurs deux mises libres et leur double validation. Pour ces seuls
duels historiques, le marché des spectateurs utilise des émeraudes ; les mises
historiques des deux adversaires restent dans leur menu existant.
Les annonces d'ouverture, de lancement et de résultat vont dans le chat du
serveur. Les combats terminés restent affichés cinq minutes. Les mises dues
restent disponibles aussi longtemps que nécessaire. La consultation se fait
par pages de douze entrées ; la pagination ne limite pas les inscriptions.
Il n'y a ni téléportation de spectateur, ni création automatique de terrain :
la carte de playtest se prépare normalement à côté.
## Combat et règles du monde
- **Joueurs : morts réelles**, selon le choix du créateur. Aucune santé
artificielle à la fin, aucun inventaire restauré par l'arène. Inventaire,
perte d'expérience, règles de conservation et tête-tombe suivent Sanctuary
et les règles effectives du monde. La mort élimine après le chemin natif
de création de la tombe.
- **Familiers : K.-O. existant**, santé persistante et récupération habituelle.
En mode mixte, le K.-O. du familier laisse son joueur combattre. La mort du
joueur élimine son camp. En mode familiers seuls, les propriétaires ne sont
pas des cibles et ne frappent pas directement les familiers adverses.
- Le dernier camp encore en lice gagne. Abandon, déconnexion pendant le
combat, changement d'individu, rappel du familier, sortie du périmètre ou
passage en créatif/spectateur éliminent. Le rayon est une distance 3D au
centre annoncé : l'altitude compte également.
- Une interruption du serveur, un rechargement du catalogue, l'expiration des
inscriptions ou une durée écoulée sans vainqueur rembourse les mises.
Un joueur déjà éliminé qui se déconnecte ne termine pas le combat des autres.
- Avant départ, se désinscrire rembourse son entrée et les paris placés sur
soi. Les autres inscriptions restent ouvertes. Le départ de l'organisateur
annule l'événement entier. Les duels historiques gardent leur contrat
d'interruption/refund antérieur.
- La permission PvP du combat ne vise que les adversaires inscrits en lice,
même si le PvP général est désactivé ou s'ils sont de la même faction.
Les dégâts directs ne franchissent pas cette frontière ; les familiers
hors combat n'apportent pas leur assistance aux combattants.
- Pose, utilisation et casse de blocs par les combattants sont désactivées,
y compris les gestes de minage/construction groupés déjà commencés.
Cela ne transforme pas le périmètre en claim : les mécanismes du monde,
cosmétiques, pièges ou interventions d'opérateurs restent ceux de la map.
## Paris en objets
La mise d'inscription est identique pour tous ; son pot revient au vainqueur.
Les spectateurs choisissent un inscrit et déposent une quantité libre de
l'objet commun, jusqu'à 3 456 unités par geste et dans la limite de leur
inventaire débloqué. Plusieurs gestes sont possibles avant le lancement.
Il n'y a pas de commission.
Le pot des spectateurs est partagé **au prorata des mises gagnantes**. Les
unités indivisibles restantes sont réparties dans l'ordre stable des UUID.
Exemple : 4 objets sur A, 2 autres sur A et 3 sur B ; si A gagne, les deux
parieurs gagnants récupèrent respectivement 6 et 3 objets. Si personne n'avait
misé sur le vainqueur, les paris sont remboursés.
Un inscrit ne peut pas parier sur son combat. Un parieur ne peut plus s'y
inscrire. Les boutons utilisent des identifiants et des révisions contrôlés
par le serveur ; aucune pile fournie par le client ne sert de preuve de fonds.
Les fonds sont pris uniquement dans les rangées réellement débloquées.
Les gains se récupèrent dans **Duels et arènes**. Un inventaire plein conserve
le solde dans le registre ; libérer même une partie d'une pile permet de le
récupérer progressivement. Aucun paiement ne tombe au sol et un joueur mort
ne peut pas encaisser dans l'inventaire en cours de remplacement.
## Contrat de sauvegarde additif
Aucun registre historique, identifiant d'objet, génération ou monde personnel
n'est converti. Nouveau fichier séparé :
`sanctuary/arena-stakes-v1.json`, schéma 1. Nouveau reçu joueur persistant et
copié à la mort : `sanctuary:arena_receipt`.
Chaque dépôt est écrit avant le débit, puis l'inventaire natif et son reçu sont
sauvegardés ensemble dans `playerdata` et relus. Le journal confirme ensuite
le débit. Chaque résultat répartit la totalité des fonds en une écriture
atomique avant le paiement. Chaque paiement sauvegarde de même l'inventaire
et son reçu avant de retirer la dette du journal. Une collecte partielle garde
le reste et ne repaie pas une tranche déjà reçue.
Au redémarrage, les combats vivants ne reprennent pas : les mises réellement
débitées sont remboursées. Un résultat déjà réparti conserve ses destinataires.
Les dépôts inachevés sont résolus à la reconnexion du payeur d'après son reçu,
sans inventer de crédit. Une erreur disque, un schéma inconnu ou un journal
invalide préserve les fichiers et suspend les transactions.
Le journal a une borne de lecture/écriture de 32 Mio pour détecter les états
anormaux. C'est une limite de stockage des opérations en attente, pas un nombre
maximum de participants. Avant un retour à une ancienne version, terminer
les événements et récupérer les soldes ; sinon restaurer une sauvegarde
complète cohérente, jamais un seul registre ou un seul fichier joueur.
## Vérifications et limites
Minecraft **26.3-pre-2**, Java 25, Fabric API **0.160.0+26.3**.
`Arena073ClientChecks` utilise un client intégré et des joueurs serveur
synthétiques avec connexions natives, dans un monde plat de développement.
Aucun serveur personnel ni EULA de serveur dédié n'est modifié.
La validation couvre trois familiers en combat autonome, des joueurs avec
morts réelles, le mode mixte, les permissions PvP, les paris, le partage exact,
les remboursements, l'inventaire plein et les composants de contenants. Une
arène de 25 inscrits vérifie la pagination sans plafond de participants.
Les menus FR/EN et l'aller-retour réseau du bouton de création sont contrôlés.
Résultat client : **réussi**, `ARENA073_PASS`, 1 min 52 s. Les tests
`Duel054Checks` sont rejoués dans la même session : validation mutuelle, anciennes
mises, projectiles natifs, K.-O., refus dun projectile tardif et remboursements
passent aussi. La mort pendant les inscriptions libère linscription sans
annuler les autres. Le journal est éprouvé avant débit, après sauvegarde du
débit, après sauvegarde du paiement et avec un schéma inconnu. Les essais
conservent les quantités exactes et refusent le schéma inconnu sans réécriture.
Journal client : `build/arena073-workspace/build/arena073-client.log`.
Captures copiées dans `build/arena073-evidence/`.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 56 s**, 124 tâches (108 exécutées). Les GameTests serveur
dédiés sont exclus conformément au refus d'accepter leur EULA ; les scénarios
multijoueurs de ce ticket tournent dans le serveur intégré du client natif.
Les **1 689 classes** compilées, les ressources et les sources Java correspondent
aux archives. Par rapport à beta.072, seules les classes de ce ticket et les
métadonnées de version évoluent ; les ressources existantes, les 88 profils et
le resource pack beta.070 restent identiques. Les **79 nouvelles clés FR/EN**
sont présentes dans les deux langues. Les archives beta.070 à beta.072 restent
inchangées.
- [Pack normal](../build/Sanctuary-beta.073.mrpack) : 9 592 283 octets.
- [Pack Test, monde plat rapide](../build/Sanctuary-Test-beta.073.mrpack) :
9 611 218 octets.
- [Reçu des artefacts et SHA-256](../build/arena073-artifact.json).
La livraison a été construite dans `build/arena073-workspace`, copie isolée
incluant la version beta.072 vérifiée, puis les seuls changements de ce ticket
ont été réintégrés dans les sources partagées. Aucun canal distant, serveur
personnel ou instance Prism n'est mis à jour.
Ce ticket fournit un outil de playtest. Il ne constitue pas une validation
exhaustive de l'équilibrage des 88 espèces, un benchmark de très grand serveur,
ni une protection compétitive contre les complicités et abandons arrangés.
Les tests de reprise ne simulent pas une panne physique du disque.
+160
View File
@@ -1,5 +1,159 @@
# Backlog Sanctuary
## SKY-077 — tourbillon au-dessus du vide
Branche `codex/cloud-vortex-beta077`. [Ticket](cloud-vortex-beta077.md).
Couche native à Y = 0, diamètre double de l’île, rotation et géométrie en cache.
Livraison groupée avec les correctifs de menus beta.076. Tests natifs, cache,
transparence Fabulous, build et archives normal/Test vérifiés.
## UI-076 — navigation et pouvoirs sélectionnés
Branche `codex/menu-navigation-beta076`. [Ticket](menu-navigation-beta076.md).
Accès direct aux combats depuis Pause, parcours prestige/faction/historique,
achats visibles et sélection du pouvoir utilitaire explicite. Parcours natif vérifié ;
distribution regroupée avec beta.077.
## PLAN-FIX-01 — retirer la recherche web (beta.075)
Branche `codex/plans-library-beta075`. [Correctif](plans-library-beta075.md).
Retirer le bouton Minecraft Schematics de K → Bibliothèque et ses libellés.
Build, sources et archives normal/Test vérifiés ; anciens packs conservés.
## FAMILIAR-05 — autonomie et ordres rapides (beta.074)
Branche `codex/familiar-autonomy-beta074`. [Contrat](familiar-autonomy-beta074.md).
Quatre règles de comportement distinctes, garde locale, sélection des menaces,
retour après poursuite, gestion des cibles inaccessibles et H bref/maintenu.
Parcours natif, régressions montures/arènes/duels et `check build` réussis.
Archives normal/Test vérifiées ; textures beta.070 conservées.
## PLAN-01 — statues et plans natifs (beta.072)
Branche `codex/statues-plans-beta072`.
[Contrat, formats et vérifications](statues-plans-beta072.md). Modèles et textures
du jeu convertis en statues creuses ; bibliothèque compatible `.litematic`,
`.schem` v2/v3 et `.nbt`, aperçu par couche, rotation, matériaux et collage
créatif contrôlé côté serveur. En survie, construction manuelle native.
Aucune dépendance Litematica/MaLiLib ajoutée.
Capture native des 88 espèces, parcours creeper créatif/survie FR/EN,
interopérabilité Litemapy, sept tests serveur et 130 tâches Gradle réussis.
Archives normal/Test vérifiées ; anciennes `.schematic` à convertir en externe.
## MB-01 — Reprendre le catalogue des multiblocs
**Ticket local ouvert le 15 septembre 2026 ; premier lot implémenté en beta.084.**
[Catalogue et décisions historiques](multiblocs-conception.md) ·
[Ticket de cadrage et lots d'implémentation proposés](multiblocs-ticket.md).
Branche documentaire réservée `codex/multiblocs-ticket`.
Premier lot choisi le 16 septembre 2026 : **clé dorée, Fourneau et Fût**,
implémenté en [beta.084](multiblocs-beta084.md) sur `codex/multiblocs-beta084`. Fermentation, présentoirs,
méga-pistons, Trémie, Carillon et Métablit restent au catalogue.
La clé dorée assemble volontairement et dissocie ; les composants restent
indépendants à la pose. Le Fût à 729 cases avec recherche et pages est accepté.
Les gestes et la chauffe du premier lot sont décrits dans sa livraison. La fusion de tout
It's Alive dans Sanctuary est différée ; le Fourneau pourrait alors remplacer
la cuisinière, selon le [cadrage corrigé](multiblocs-itsalive-contrat.md).
Aucun nouveau multibloc ni binaire livré ici.
Les huit candidats de la [recherche complémentaire](multiblocs-recherche.md)
sont rejetés par le créateur, jugés sans intérêt ou contraires au contrat
Minecraft Vanilla. Reprendre le catalogue antérieur pour la liste d'implémentation.
## FAMILIAR-04 — personnalité et montures natives (beta.071)
Branche de départ `codex/familiar-personality-beta071`.
[Contrat et vérifications](familiar-personality-beta071.md). Quatre caractères
stables avec tendances despèce ; priorité des ordres, poursuite plus vive et
verrouillage de combat conservé. Escalade des deux araignées, Souffle du
Nautilus pour les deux variantes colossales et surface de lave du Strider.
Audit des 14 espèces montables natives, parcours client et 124 tâches Gradle
réussis ; archives isolées de la beta.072 en cours, resource pack beta.070.
## RP-02 — textures versionnées et actives par défaut (beta.070)
Branche `codex/resourcepack-beta070`.
[Contrat et vérifications](resourcepack-beta070.md). Import des dessins fournis,
source et manifeste dempreintes enregistrés dans Git ; pack natif activé par
défaut et désactivable. Sept textures de faim/saturation mises à jour, respiration
vanilla conservée. Client natif, build, deux MRpacks et ZIP autonome vérifiés.
## FAMILIAR-FIX-02 — retour des ordres sur H (beta.069)
Branche `codex/familiar-orders-h-beta069`.
[Contrat et tests](familiar-orders-beta069.md). H pour les ordres, F pour
l’échange des mains, G pour la technique. Conversion unique de lancien
raccourci F en H ; suppression du filtrage de l’échange natif. Parcours
client et compilation vérifiés, deux archives locales conformes.
## LOAD-03 — animation discrète de lattente (beta.068)
Branche `codex/loading-animation-beta068`.
[Résultat et vérifications](loading-animation-beta068.md) : neuf glyphes SGA
avec une lumière mobile, animés par lhorloge de rendu ; étapes réelles et
carte des chunks conservées. Huit captures natives comparées, FR/EN inspectés,
compilation et archives normal/Test vérifiées. Introduction et cache inchangés.
## FAMILIAR-FIX-01 — touche et vol du dragon (beta.067)
Branche `codex/familiar-controls-beta067`.
[Contrat et vérifications](familiar-controls-beta067.md) : ordres sur F,
remappage unique de l'ancien H et prévention du double déclenchement de
l'échange des mains ; orientation du dragon corrigée. Plané des petits
vérifié, montée du grand porté avec Espace et monture colossale vérifiées.
Compilation et deux archives validées localement.
## HEAD-FIX-01 — modèles animés superposés (beta.066)
Branche `codex/cosmetic-render-beta066`.
[Résultat et tests](head-render-beta066.md). Doublon de coffre reproduit dans
le collecteur natif puis corrigé ; une seule cloche animée avec son support.
Ouvertures/fermetures, aliments, autres modèles et douze cas sur des mobs
adultes ou bébés vérifiés. Build et archives validés localement.
## BOAT-FIX-01 — collisions des coques (beta.065)
Branche `codex/boat-collisions-beta065`.
[Résultat et tests](boat-collisions-beta065.md) : coque entière couverte,
virages contrôlés avant la translation, recul conservé. Passage au travers
des murs reproduit puis corrigé ; 88 variantes et seize parcours pilotés
vérifiés en client natif et serveur intégré. Build et deux archives validés
localement ; pas de déploiement personnel.
## LOAD-02 — cache et étapes réelles de préchargement (beta.064)
Branche `codex/loading-cache-beta064`.
[Contrat, mesures et vérifications](loading-cache-beta064.md). Plans dérivés
mis en cache sur disque, validation des données et recalcul automatique si
une entrée manque ou est corrompue. Réouverture mesurée à 2,1 s contre 58,7 s
lors de laudit beta.063 ; première création à 122 s, introduction complète.
Étapes réelles du serveur, écran natif épuré sans portail du Nether. Pas de
migration des chunks ou des journaux dexpansion. Build, récupération dun cache
corrompu, huit ouvertures classiques/plates et archives vérifiés localement.
## RP-HUD-01 — base de textures modifiable
**Préparée le 15 septembre 2026**, branche `codex/resourcepack-hud-base`.
[Résultat et vérifications](resourcepack-hud-base.md) : 53 sprites vanilla
de vie et de faim, manifeste 26.3-pre-2 et guide de retouche FR/EN dans le
resource pack Sanctuary existant. Les dessins seront modifiés par le créateur.
Ce lot graphique séparé ne livre pas de nouveau binaire du mod.
## CAPE-01 — Des capes rares aux pouvoirs durables
**Ticket préparé le 15 septembre 2026 ; à arbitrer puis à implémenter.**
[Intention, premier lot proposé et critères d'acceptation](capes-gameplay.md).
Branche documentaire réservée `codex/capes-ticket` ; branche d'implémentation
prévue `codex/capes-gameplay`.
Les capes apportent généralement un bonus passif permanent, proposé tant
qu'elles sont portées. Des capes très puissantes restent possibles, avec rareté,
contreparties ou périodes actives à éprouver selon leur impact. Le ticket
prépare trois capes aux usages distincts, leur acquisition rare en survie et
les essais de cumuls, de circulation et de progression. Catalogue, valeurs,
malus et règles temporelles restent des propositions. Cape Zéro est actuellement
visuelle ; aucun nouveau pouvoir ni changement de binaire livré par ce ticket.
## ANO-01 — Les doubles de Steve
**Conception du 15 septembre 2026 ; non implémentée.**
@@ -1566,3 +1720,9 @@ secrets et traversées. Comparer les champs natifs 27, 30.1, 30.4 et 30.5 par
coupes et cartes scientifiques, archivées avec leurs sources et leurs graines.
Les expéditions anciennes et les îles supérieures ciblées restent à définir.
Voir [la génération](generation-alpha30.5.md) et [latlas](terrain-atlas.md).
## ARENA-01 — beta.073 — duels publics et arènes
Livré et vérifié : trois modes de combat, inscriptions sans plafond pour les arènes,
paris en objets, annonces et liste publique. Joueurs avec morts réelles.
[Contrat et essais](arenas-beta073.md).
+100
View File
@@ -0,0 +1,100 @@
# beta.065 — collisions des bateaux collectifs
Branche `codex/boat-collisions-beta065`.
## Problème et portée
Les huit formes de bateaux et radeaux collectifs utilisent le déplacement natif
côté serveur. Leurs anciennes limites étaient fondées sur une tuile carrée de
1,375 bloc, alors que les modèles ont une longueur de 28 pixels par module.
La coque visible dépassait donc sa collision dans le sens longitudinal. Un
virage agrandissait aussi directement les limites sans vérifier le terrain ;
après cette intrusion, le déplacement natif pouvait traverser le mur.
Les limites couvrent désormais les coques complètes, et le volume balayé
par chaque virage est contrôlé avant le déplacement. Le mouvement natif reste utilisé pour
les translations, lappui au sol et sur leau, les formes de blocs et les
collisions avec les autres véhicules. Les sièges, les commandes partagées,
les moteurs, les vitesses et les règles de chute des piles restent inchangés.
Les modèles et les textures ne changent pas. Les bateaux simples Minecraft
conservent leur comportement. Aucune modification de chunk, de recette, de
journal ou de format de sauvegarde ; les identifiants existants restent stables.
Les positions enregistrées des anciens bateaux ne sont pas déplacées par la
mise à jour : seule leur collision utilise les dimensions corrigées.
## Collision de la coque
Les huit formes sont couvertes : 1 × 2, 2 × 1, 1 × 3, 3 × 1, 2 × 2,
2 × 3, 3 × 2 et 3 × 3, sur les onze bois. Largeur et longueur de la coque :
- Bois : `colonnes + 0,25` × `rangées × 1,75 + 0,25` blocs, rebords compris.
- Bambou : `colonnes × 1,25` × `rangées × 1,75` blocs.
Comme dans le moteur natif, la collision est une boîte alignée sur les axes ;
elle englobe la coque orientée. Les rames animées restent décoratives. Un virage
vérifie aussi les extrema entre ses deux orientations, pour quun coin ne
traverse pas un mur avant de revenir dans une position libre. Au contact,
linertie de rotation sarrête ; le recul et le glissement le long du mur restent
résolus par Minecraft. Les rotations de placement ou de téléportation ne sont
pas remplacées par des tentatives de conduite.
## Vérification
Client Minecraft 26.3-pre-2 et serveur intégré de développement, monde plat
neuf de graine `65`. Le test avant correction traverse effectivement le mur
situé en X=8 : après quinze mouvements avec virage, le centre atteint X=12,55.
Ce même cas passe après correction.
- **88 variantes** : **3 168 déplacements** contre des murs dans les deux sens
de X et Z, huit orientations, diagonales et déplacements de vingt blocs en
un appel ; collision native détectée et recul libre de 1,5 bloc.
- **440 virages** : contact et tentative de déplacement dans le mur, virages
libres, puis cas où les orientations de départ et darrivée sont libres
mais où le milieu du virage heurterait le mur. Ce dernier cas est refusé.
- **128 passages** contre les formes natives minces des barrières et vitres,
sur bateaux et radeaux, sans traversée ni recouvrement des blocs.
- **704 modèles orientés** : les sommets réels des fonds et rebords sont inclus
dans les collisions, côté client. Les rames sont explicitement exclues.
- **16 parcours pilotés avec les vraies touches**, huit formes en chêne et en
bambou, répartis entre eau et vol avec Allay et les quatre directions :
avancer contre le mur, maintenir le virage, reculer. Les limites serveur et
client restent à lintérieur des murs, le pilote reste assis et le recul
déplace effectivement le bateau. Deux captures de contact ont été relues.
Journaux locaux : `build/boat065-before.log`,
`build/boat065-control-before.log`, `build/boat065-fixed-probe.log`,
`build/boat065-client.log`. Captures : `build/boat065-evidence/`.
Le parcours complet passe en **1 min 59 s**.
```sh
./gradlew :sanctuary:runClientGameTest -x :sanctuary:runGameTest \
-PsanctuaryClientTests=true -PsanctuaryBoat065ClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryClientNoVsync=true
```
Les essais utilisent le serveur intégré ; aucun serveur dédié ni EULA acceptée.
Le test client couvre la synchronisation locale ; une session avec plusieurs
clients distants et latence na pas été relancée pour ce correctif.
## Livraison locale
`./gradlew check build assemblePack assembleTestPack` réussi en **2 min 8 s**,
**122 tâches**, avec `-x :sanctuary:runGameTest -PsanctuaryClientTests=true
-PsanctuaryQuickTests=true`. Les essais serveur intégrés ci-dessus remplacent
les tests dédiés exclus conformément au refus antérieur daccepter leur EULA.
Les archives normal/Test sont vérifiées : **1 645 classes** conformes à la
compilation, aucune classe de test embarquée, mêmes ressources et dépendances
quen beta.064. Seule la source principale `CrewBoat` change, en plus des
métadonnées de version. Modèles, textures, recettes, sièges, moteurs et autres
systèmes restent conservés. Les anciennes archives beta.064 sont intactes.
- [Pack normal beta.065](../build/Sanctuary-beta.065.mrpack), 5721084 octets.
SHA-256 : `71ff1736817e2953f88a8afda5858b2e7ae23c360efdb7e25f149cbff049d764`.
- [Monde plat rapide beta.065](../build/Sanctuary-Test-beta.065.mrpack), 5740018 octets.
SHA-256 : `4c4931146a05d6f57e9abf2303b039ab7a7bc16471a0b864873b86bef297431d`.
Reçu `build/boat065-artifact.json`, build `build/boat065-check.log`.
Aucun canal publié, instance Prism ou monde personnel modifié.
+74
View File
@@ -0,0 +1,74 @@
# beta.091 — Interagir avec les animaux embarqués
Branche `codex/boat-passenger-interactions-beta091`, Minecraft 26.3.
## Contrat
Depuis un bateau, viser un mob directement installé sur un siège permet les
interactions habituelles : clic gauche pour le frapper, clic droit pour utiliser
son objet de tête ou son interaction native. Cela couvre les bateaux/radeaux
Minecraft et tous les formats Sanctuary. Les coups sur les animaux ordinaires
infligent leurs dégâts habituels ; les familiers conservent leur réaction sans
blessure hors des combats prévus, avec déclenchement de leur équipement.
Le ciblage natif excluait les entités partageant le véhicule racine du joueur.
L'exception est limitée au curseur du joueur et aux mobs assis directement dans
son bateau. Portée, obstacles et choix de la cible la plus proche restent ceux
de Minecraft. Les collisions des projectiles ne sont pas modifiées.
Le bateau limite aussi normalement le regard à ±105 degrés. Avec un mob à bord,
les joueurs peuvent désormais regarder autour deux à 360 degrés, notamment
vers le siège arrière. Les commandes de déplacement et la rotation du bateau
restent indépendantes du regard ; les mobs gardent leur orientation native.
La protection de portage des familiers distingue désormais un siège de bateau
d'un familier porté sur la tête ou monté. Les protections entre joueurs portés
restent en place. Les dégâts et interactions continuent à être validés côté
serveur ; aucun nouveau paquet, inventaire, identifiant ou format de sauvegarde.
Aucune modification des mondes existants ni de la génération.
## Vérifications
Le parcours `BoatPassengers091ClientChecks` crée un monde plat de développement,
graine 42, et utilise le curseur natif et les vrais clics souris. Il teste un
coffre porté puis un distributeur chargé sur une poule embarquée.
Le parcours natif passe en **1 min 13 s** sur Minecraft 26.3/macOS :
- 13 cas : bateau et radeau vanilla, les huit formats Sanctuary, radeau 3 × 3,
poule bébé et familier installé comme passager pour vérifier sa protection.
- Vrai ciblage du corps depuis le siège, y compris vers larrière ; aucun
remplacement artificiel du résultat de visée dans le test.
- Clic droit : ouverture du coffre natif contenant les sept diamants attendus.
- Clic gauche : perte de vie de la poule, une flèche consommée et un projectile
réel créé par le distributeur, sans éjection de lanimal.
- Familier : même tir au toucher, vie inchangée. Une fois porté sur la tête,
protection symétrique conservée et aucun nouveau déclenchement au toucher.
```sh
./gradlew :sanctuary:runClientGameTest -x :sanctuary:runGameTest \
-PsanctuaryClientTests=true -PsanctuaryBoatPassengers091ClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryClientNoVsync=true
```
Journal : `build/boat091-client.log`, marqueur `BOAT091_PASS`.
## Livraison
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 20 s**, 124 tâches. Journal : `build/boat091-check.log`.
Les deux exports sont vérifiés contre les JAR et leurs sources. Les textures,
modèles, données et JEI restent identiques à beta.090 ; ses archives sont intactes.
Le reçu local est `build/boat091-artifact.json`.
- [Pack normal](../build/Sanctuary-beta.091.mrpack), SHA-256
`0d0e847e5b8f22637b6e280d0694ab5a2cdb8cb01a47442d92c5103e3e3f53ac`.
- [Pack Test](../build/Sanctuary-Test-beta.091.mrpack), SHA-256
`392773b8e5fc8948cdb5ea9cd7e8cc8cd97ed43bdc5fe413fef32b05758f8edc`.
## Limites
Les essais utilisent un client natif et son serveur intégré de développement ;
pas de session LAN à deux clients pour cette livraison. Les tests dédiés restent
exclus et aucune EULA nest acceptée. Aucun déploiement dans une instance
personnelle ni publication du canal packwiz.
+85
View File
@@ -0,0 +1,85 @@
# beta.080 — portée de Construction et progression de Minage
Branche `codex/build-mining-balance-beta080`, Minecraft 26.3-pre-2, textures beta.070.
Construction augmente la distance de pose. Minage augmente indépendamment
la distance et la vitesse de casse. Le départ est inférieur au vanilla ;
les derniers paliers le dépassent modérément. Les coûts, sept achats par
compétence et tailles des groupes restent ceux du système existant.
| Rang | Vitesse de minage, référence vanilla | Portée de minage | Portée de pose |
| --- | --- | --- | --- |
| 0 | 75 % | 3 blocs | 3 blocs |
| 1 | 85 % | 3,5 blocs | 3,5 blocs |
| 2 | 95 % | 4 blocs | 4 blocs |
| 3 | 100 % | 4,5 blocs | 4,5 blocs |
| 4 | 107,5 % | 4,75 blocs | 4,75 blocs |
| 5 | 115 % | 5 blocs | 5 blocs |
| 6 | 122,5 % | 5,25 blocs | 5,25 blocs |
| 7 | 130 % | 5,5 blocs | 5,5 blocs |
Chaque colonne dépend de sa propre compétence. La vitesse sapplique via
lattribut natif de casse, avec les outils, enchantements et autres effets.
Les portées préservent les bonus externes de lattribut dinteraction ;
les valeurs du tableau supposent cet attribut à sa valeur survival de base.
Créatif, spectateur et joueurs sans progression Sanctuary conservent leur
portée native. Les infobulles FR/EN affichent le palier actuel et le suivant.
La visée peut sélectionner le plus éloigné des gestes disponibles ; les
validations de chaque action appliquent ensuite sa propre portée. Ainsi,
voir le contour dun bloc naccorde pas forcément la portée pour le miner
ou y poser un bloc : le même contour sert aux interactions ordinaires.
La portée des coffres, autres interactions et attaques naugmente pas.
Un coffre hors de portée native peut servir de support de construction,
sans souvrir à distance. La main gauche et les plantes aquatiques suivent
la portée de Construction.
Les deux sélections groupées vérifient leur ancre avec leur compétence.
Le plan et la veine verrouillés conservent leur contrat : les cellules du
groupe peuvent dépasser la portée de lancre. Les droits, protections,
collisions, stock, conditions de pose et chunks chargés restent vérifiés.
La validation réseau conserve la tolérance native de distance à lentrée
des paquets ; la pose vérifie aussi la distance du point cliqué côté serveur.
Aucun identifiant, schéma de sauvegarde ni terrain modifié. Les anciens
rangs reçoivent les nouvelles valeurs à leur application habituelle ; un
reset ou prestige revient aux valeurs de départ.
## Vérification
`Balance080ClientChecks` réussit en **1 min 03 s**, dans un monde plat solo
jetable, avec les gestes natifs et les paquets réels client/serveur :
- Les huit rangs, vitesse native de casse dun bloc et composition des bonus.
- Pose proche au rang zéro, refus de la pose lointaine sans perte de stock.
- Minage et pose au-delà de la portée vanilla ; indépendance des deux compétences.
- Pose en main gauche et refus serveur dun paquet visant trop loin.
- Coffre lointain utilisable comme support sans ouvrir son inventaire ; un
briquet en main principale ne profite pas de la portée du bloc en main gauche.
- Construction dun plan complet de neuf blocs à la nouvelle portée.
- Vein mining de neuf blocs à cette portée, regard détourné après verrouillage.
- Pose dun nénuphar, retour aux rangs zéro, créatif et joueur sans progression.
Journal : `build/balance080-client.log`. Les interactions des blocs portés
sur la tête ont également été relues : elles passent par leur contexte
propre, sans la pose de bloc interceptée ici.
`check build assemblePack assembleTestPack` réussit en **2 min 27 s**,
124 tâches (104 exécutées, 20 à jour), avec `-x :sanctuary:runGameTest`.
Journal : `build/balance080-check.log`.
Les **1 700 classes** du JAR correspondent aux fichiers compilés. Les 17
classes ajoutées ou modifiées par rapport à beta.079 concernent uniquement
ce ticket ; quatre libellés évoluent par langue. Sources et ressources
vérifiées, textures beta.070 identiques, même JAR intégré aux deux packs.
Les archives beta.079 sont conservées à lidentique.
Reçu : `build/balance080-artifact.json`. Aucun déploiement personnel.
Le serveur dédié reste exclu conformément au refus de son EULA ; le test
natif ci-dessus utilise le serveur intégré.
## Archives
- [Sanctuary-beta.080.mrpack](../build/Sanctuary-beta.080.mrpack).
SHA-256 : `feb20b882442149789ed6d90e32073aa6201314a57782105a0330394c76e796d`.
- [Sanctuary-Test-beta.080.mrpack](../build/Sanctuary-Test-beta.080.mrpack).
SHA-256 : `fc277f081f160ae8ae835501cddddd572cdffe4749454e1652a080a117bfb941`.
+43
View File
@@ -0,0 +1,43 @@
# beta.081 — portée de pose jusqu’à six blocs
Branche `codex/build-reach-six-beta081`, Minecraft 26.3-pre-2, textures beta.070.
À la demande du joueur, Construction atteint maintenant **six blocs**.
Les portées des rangs 0 à 7 sont : **3 ; 3,5 ; 4 ; 4,5 ; 4,75 ; 5 ; 5,5 ; 6**.
Le rang 6 est également ajusté pour terminer par deux gains dun demi-bloc.
La référence reste lattribut dinteraction vanilla sans bonus externe.
Le minage conserve sa portée maximale de 5,5 blocs et sa vitesse de 130 %.
Le contrat client/serveur, la pose en main gauche, les plans groupés et les
infobulles FR/EN utilisent déjà la même source des valeurs. Aucun changement
des coûts, identifiants, sauvegardes ou mondes ; les archives beta.080 restent
immuables.
## Vérification
Le scénario natif `Balance080ClientChecks`, ajusté à cette courbe, réussit
en **1 min 21 s** dans un monde solo jetable : pose réelle à 5,75 blocs,
refus serveur dun clic à 6,25 blocs sans perte de stock, et régression des
huit rangs, du minage indépendant, de la main gauche, des groupes, des
nénuphars, du créatif et des bonus externes.
Journal : `build/reach081-client.log`.
`check build assemblePack assembleTestPack` réussit en **2 min 30 s**,
124 tâches (103 exécutées, 21 à jour), avec `-x :sanctuary:runGameTest`.
Journal : `build/reach081-check.log`.
Les 1 700 classes et les sources correspondent aux fichiers compilés :
seule `BuildingLimits` change par rapport à beta.080. Ressources et textures
identiques, même JAR dans les deux packs, archives beta.080 préservées.
Reçu : `build/reach081-artifact.json`.
Le serveur dédié reste exclu
conformément au refus de son EULA ; le test utilise le serveur intégré.
Aucun déploiement personnel.
## Archives
- [Sanctuary-beta.081.mrpack](../build/Sanctuary-beta.081.mrpack).
SHA-256 : `7f789522eaa4a2574cc9bee4a4def7862a4893c8f462e88502ca858440c8ee02`.
- [Sanctuary-Test-beta.081.mrpack](../build/Sanctuary-Test-beta.081.mrpack).
SHA-256 : `7811cdc47ab4a8af2fc6ccd530775eb4a239f18894965362a1c57a5e0f12ded5`.
+205
View File
@@ -0,0 +1,205 @@
# CAPE-01 — Des capes rares aux pouvoirs durables
**Ticket local rédigé le 15 septembre 2026 — à arbitrer, puis à implémenter.**
Cette livraison prépare le ticket ; les effets et l'acquisition décrits ci-dessous
ne sont pas implémentés.
- Branche documentaire réservée : `codex/capes-ticket`.
- Branche d'implémentation prévue : `codex/capes-gameplay`.
- Aucun numéro `beta.xxx` réservé pour cette préparation documentaire.
## Ce que le joueur doit pouvoir faire
Trouver une cape Sanctuary doit être un événement : un objet très rare,
reconnaissable et désirable, que l'on a envie de porter pour son pouvoir.
Les capes donnent généralement un **bonus passif permanent**. Certaines peuvent
offrir un pouvoir exceptionnel, voire volontairement « cheaté », si son impact
reste acceptable dans la partie collective.
Un catalogue riche est souhaitable : il peut contenir beaucoup de modèles,
y compris plusieurs capes très puissantes. La diversité du catalogue et le
nombre d'exemplaires en circulation sont deux choix distincts.
### Intentions exprimées par le créateur
- Privilégier les bonus durables et l'envie d'utiliser les capes.
- Faire des capes des objets très rares.
- Garder ouverte la possibilité de malus ou de contreparties.
- Accepter des pouvoirs très forts ; leur puissance seule ne les exclut pas.
- Examiner leurs conséquences réelles sur le gameplay et les autres joueurs.
- Envisager la rareté et des périodes de fonctionnement comme moyens
d'équilibrage, sans rendre toutes les capes temporaires.
### Interprétation proposée de « permanent »
Le pouvoir reste disponible **tant que la cape est équipée dans sa case dédiée**,
sans renouveler une potion ou appuyer régulièrement sur une touche. Posséder
la cape dans un coffre ou l'inventaire n'accorde rien. Le retrait arrête son
effet ; découvrir plusieurs capes ne donne pas plusieurs bonus permanents au
personnage. Cette interprétation reste à confirmer avant le code.
Les exceptions temporaires sont annoncées cape par cape. Proposition : la cape
reste un objet de collection après sa période active ; c'est son pouvoir qui
s'endort. Une cape consommable ou détruite à l'expiration serait un autre choix,
à expliciter, pas une conséquence implicite du mot « temporaire ».
## État réel du socle
La [beta.018](companions-cosmetics-beta018.md) a livré la case cape, les gestes
d'équipement, la sauvegarde et le rendu partagé. Le code consulté contient une
seule cape, `sanctuary:zero_cape`, disponible en créatif ou par `/give`.
**Cape Zéro est actuellement visuelle : aucun bonus de gameplay n'est appliqué
par son service.** Son apparence est provisoire. Le filtre d'équipement et le
rendu reconnaissent explicitement cet objet ; un catalogue demande leur extension.
Les [lootboxes de capes](vision.md#panneaux-et-lootboxes) appartiennent à la vision.
Elles ne constituent pas une acquisition en survie déjà livrée. Les familiers,
la progression, le portage, les bateaux et le temps réel existent et devront
être pris en compte lors des essais des effets retenus.
## Périmètre proposé pour un premier lot jouable
Livrer **trois capes aux usages distincts**, leur description FR/EN, leur effet
serveur et une première voie d'obtention rare effectivement jouable. Le choix
des trois capes ci-dessous est une proposition de prototype, pas un catalogue
approuvé. Les autres modèles pourront suivre par petits lots.
| Piste FR / EN | Pouvoir envisagé | Point d'équilibrage à éprouver |
| --- | --- | --- |
| **Cape de la Brise / Breeze Cape** | Bonus permanent de vitesse de déplacement au sol. | Utilité quotidienne ; cumul avec Sprint, potions, familiers et ralentissement du portage. Un bonus simple peut rester sans malus. |
| **Cape du Brasier / Ember Cape** | Forte augmentation des dégâts de mêlée ; réduction de la vie maximale comme contrepartie possible. | Gain offensif réellement sensible ; risque assumé et lisible. Vérifier si le malus influe sur les combats ou s'annule facilement par une combinaison. |
| **Cape de l'Éclipse / Eclipse Cape** | Vol libre pendant une courte fenêtre récurrente, pouvoir volontairement exceptionnel. | Accès entre îles, exploration, transport et sortie de fenêtre en plein vol. Durée, fréquence et éventuelles restrictions à décider après essai. |
Pour chaque cape sélectionnée, renseigner avant implémentation : nom FR/EN,
identifiant stable, visuel et provenance, effet chiffré, conditions, éventuel
malus, cumuls, acquisition, fréquence d'obtention et règle temporelle complète.
Le devenir de Cape Zéro reste une décision distincte : ne pas attribuer
automatiquement un nouveau pouvoir aux exemplaires existants.
Ce lot réutilise la case existante. Il ne nécessite ni nouveau monde, ni nouvelle
génération, ni économie complète, ni système général de quêtes ou de lootboxes.
La source de récompense doit être choisie parmi les mécanismes effectivement
disponibles au démarrage ; si elle exige un chantier autonome, la référencer
comme dépendance. Un prototype accessible uniquement par `/give` valide les
pouvoirs, mais ne clôt pas le résultat « trouver une cape rare en survie ».
## Équilibrage : mesurer ce que la cape change
**La rareté ne suffit pas à prouver l'équilibre.** Une cape obtenue une seule fois
peut servir quotidiennement, être prêtée à tout un groupe ou aider son détenteur
à en obtenir d'autres. Une faible probabilité répétable peut finir par produire
beaucoup d'exemplaires. À l'inverse, une cape très forte dans un contexte précis
peut créer un moment mémorable sans dominer toute la progression.
L'objectif proposé est que plusieurs capes aient des usages désirables, tout en
gardant un parcours intéressant sans cape. Les exceptions qui court-circuitent
une étape doivent être identifiées et assumées dans leur fiche.
| Axe | Essai à réaliser et décision à en tirer |
| --- | --- |
| **Exploration et vide** | Comparer un trajet entre îles sans cape, avec cape et avec moyens de transport existants. Identifier les risques supprimés, les accès anticipés et l'utilité restante des infrastructures. |
| **Combat PvE et PvP** | Mesurer dégâts, survie et possibilité de réponse à équipement comparable, puis en combinaison forte. Décider explicitement du traitement PvP ; ne pas le désactiver par défaut dans la conception. |
| **Progression et production** | Relever temps gagné, ressources et XP obtenues. Vérifier si une cape rend superflus une compétence, une potion, un familier ou une étape collective. |
| **Cumuls et coopération** | Éprouver cape + familier + équipement + potions, portage et prêt entre joueurs. Une seule case cape ne limite pas les autres sources de bonus. |
| **Rareté dans le temps** | Estimer les exemplaires et détenteurs après une semaine et un mois, pour un joueur occasionnel, un joueur intensif et un groupe qui mutualise les récompenses. |
| **Plaisir et choix** | Observer si les joueurs veulent réellement porter chaque cape, changent selon l'activité ou choisissent toujours la même. Un malus qui conduit à tout laisser au coffre rate l'intention. |
Ajuster en priorité le contexte d'utilité, les cumuls, l'acquisition ou la période
active lorsqu'ils permettent de conserver un pouvoir spectaculaire. Réduire
la puissance ou ajouter un malus reste possible, sans imposer une pénalité à
chaque cape. Une cape maudite à malus seul reste une piste à arbitrer.
### Rareté et circulation
- Fixer la source, les joueurs éligibles, la fréquence des tentatives, le taux
ou quota éventuel et la possibilité de renouveler la récompense.
- Distinguer rareté d'un modèle et rareté de l'ensemble : beaucoup de modèles
très rares peuvent rendre l'obtention d'une cape quelconque fréquente.
- Examiner les prêts, échanges, doublons, récompenses rejouées et New Game+.
Liaison au joueur et exemplaire unique au serveur sont des options, pas des
règles acquises. Les nouveaux arrivants doivent être inclus dans l'essai.
- Documenter les hypothèses de calcul et confronter la rareté attendue à des
essais. Un taux de butin bas, seul, n'est pas un critère d'acceptation suffisant.
### Périodes d'activité des capes exceptionnelles
Choisir, pour chaque cape concernée, **un modèle temporel explicite** : fenêtre
commune récurrente, durée depuis l'obtention, ou budget de temps d'utilisation.
Une période d'obtention limitée et une période d'effet limitée sont distinctes.
Le contrat précise l'horloge utilisée, début, fin, fréquence, temps hors ligne,
arrêts serveur et effet des changements de mode Vanilla/Real Time. Une fenêtre
liée à une heure réelle doit aussi être essayée du point de vue des joueurs
qui ne peuvent pas se connecter à cette heure.
Le serveur fait autorité. Retirer, rééquiper, prêter, mourir, se reconnecter ou
redémarrer ne doit pas réinitialiser involontairement une durée ou une recharge.
L'interface indique l'état actif/dormant, le temps restant et la prochaine
occasion lorsqu'elle est prévisible. Prévoir l'avertissement et la transition
de fin : pour le vol, définir une sortie praticable, sans mort surprise ni
prolongation illimitée par rééquipement.
## Contrat technique à écrire avant le code
- Une seule cape active par joueur ; effets et conditions calculés côté serveur.
Une préférence visuelle ne peut pas accorder ou prolonger un pouvoir.
- Appliquer et retirer uniquement la contribution de la cape. Préserver un
effet identique venant d'une potion ou d'un familier ; préciser addition,
multiplication, priorité ou plafond pour chaque cumul pertinent.
- Traiter changement de cape, mort, tombe, `keepInventory`, changement de
dimension, reconnexion et New Game+ avec les règles d'inventaire existantes.
Définir aussi le retrait d'un bonus de vie ou d'un malus de vie maximale,
sans soin gratuit par alternance de capes.
- Conserver `sanctuary:zero_cape` et les emplacements existants. Tout ajout de
données persistantes, notamment temporelles, exige un contrat de migration
explicite et des essais sur des copies de sauvegardes de développement.
- Décrire en FR/EN bonus, contrepartie, conditions et durée avant équipement.
Vérifier le rendu porté et les élytres sur la version Minecraft exacte du lot.
## Critères d'acceptation de la future livraison
- [ ] Les trois fiches sont complètes ; valeurs, obtention, cumuls et traitement
temporel sont fixés, avec les conséquences fortes assumées par la conception.
- [ ] Un joueur peut obtenir une cape par la voie de survie retenue, l'équiper,
comprendre son pouvoir et constater son effet réel. Une récompense unique
ne peut pas être réclamée plusieurs fois par reconnexion.
- [ ] Le bonus permanent reste actif au-delà de la durée d'une potion ordinaire
tant que la cape est portée. Stockage et retrait ne laissent aucun bonus indu.
- [ ] Deux joueurs voient un équipement cohérent ; changement rapide de cape,
effet concurrent et prêt ne produisent ni cumul résiduel ni duplication.
- [ ] Les limites de période fonctionnent avant, pendant et après la fenêtre,
y compris après arrêt/reprise et transfert ; la sortie d'un pouvoir dangereux
respecte la transition annoncée.
- [ ] Mort/tombe, `keepInventory`, dimension, reconnexion, redémarrage et
New Game+ conservent les objets et durées selon le contrat de migration.
- [ ] Un compte rendu compare sans cape, chaque cape seule et les combinaisons
les plus fortes, en début et en fin de progression, en solo et à 24 joueurs.
Il relève gains, abus possibles, circulation attendue et ajustements retenus.
- [ ] Aucun pouvoir n'est déclaré équilibré sur la seule base de sa rareté ou
d'un build réussi ; les limites de l'essai sont documentées.
- [ ] `./gradlew check build` passe avec les tests utiles aux effets retenus ;
`./gradlew assemblePack` vérifie la distribution. Versions et note de livraison
suivent [le compteur bêta](versioning.md). Parcours client FR/EN vérifié.
## Décisions encore ouvertes
1. Confirmer « permanent tant que porté » et le rôle des capes à malus seul.
2. Choisir les trois premières capes, leurs valeurs et le devenir de Cape Zéro.
3. Choisir la première acquisition jouable et la rareté visée, puis décider
des échanges et des éventuels quotas.
4. Choisir la règle temporelle des exceptions, leurs cumuls, leur traitement
PvP et les étapes de progression qu'elles peuvent volontairement dépasser.
## Références vérifiées pour cette préparation
- [Vision : progression](vision.md#inventaire-prestige-et-déblocages) et
[récompenses](vision.md#panneaux-et-lootboxes).
- [Service actuel des accessoires](../mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/AccessoryService.java),
[filtre et stockage](../mods/sanctuary/src/main/java/fr/koka/sanctuary/inventory/AccessoryContainer.java)
et [rendu](../mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/AccessoryAvatarRendererMixin.java).
- [Catalogue des familiers beta.032](spawn-eggs-v2-catalogue-beta032.md), à
confronter au code courant lors des essais de cumul.
Validation de ce ticket : lecture du socle, vérification des références locales
et relecture du diff documentaire. Aucun essai de gameplay ni build exécuté
pour cette rédaction ; les critères ci-dessus concernent la future implémentation.
+49
View File
@@ -0,0 +1,49 @@
# beta.077 — tourbillon de nuages au-dessus du vide
Branche `codex/cloud-vortex-beta077`. Minecraft 26.3-pre-2, textures beta.070.
La dernière précision du créateur fixe la couche à **Y = 0**, et non 64.
Les nuages supérieurs restent présents. Un tourbillon continu tourne autour
du centre de l’île de Sanctuary ; son diamètre vaut deux fois celui de l’île
(1 448 blocs / environ 91 chunks pour l’île habituelle de 724 blocs).
Une révolution dure 20 minutes de simulation. Le style garde les cellules de
12 blocs, l’épaisseur de 4 blocs et l’éclairage des nuages Minecraft.
Le serveur transmet uniquement le centre et le diamètre déjà définis par le
générateur. Aucun chunk, bloc, sauvegarde ou génération nest modifié. Les
mondes classiques/plats et les autres dimensions ne reçoivent pas de couche.
Les réglages de nuages désactivés/rapides/détaillés sont respectés.
La géométrie est calculée une fois puis conservée sur le GPU. La rotation et
la couleur passent par les uniforms natifs, sans remaillage à chaque mouvement
de caméra. Les passes normales et de transparence Fabulous réutilisent les
pipelines de nuages du jeu. Les buffers sont libérés à la déconnexion.
Cette livraison inclut les [correctifs de menus beta.076](menu-navigation-beta076.md)
et le retrait du lien web de la [bibliothèque beta.075](plans-library-beta075.md).
## Vérifications
Parcours client natif réussi en 1 min 8 s : formes connectées pour les îles de
512, 724 et 1 024 blocs ; rendu rapide/détaillé/Fabulous (passages OIT réellement
exécutés), rotation, vues au-dessus/dessous/dans la couche, désactivation et
extension finie. Une seule construction de géométrie pendant les mouvements
et changements de qualité. 5 888 cellules pour l’île habituelle.
Captures : `build/cloud077-evidence/`.
`check build assemblePack assembleTestPack` réussit en 3 min 22 s (124 tâches),
avec le serveur dédié exclu conformément au refus de son EULA.
Les 1 693 classes et les sources du JAR correspondent au build ; seuls les
menus et le nouveau rendu changent depuis beta.075. Les textures, données de
jeu et le resource pack restent identiques. Les onze libellés sont présents
en FR/EN. Les deux packs sont vérifiés et 155 archives précédentes restent
identiques. Reçu : `build/cloud077-artifact.json`.
Pas de déploiement dans une installation personnelle ni de serveur dédié.
Les shaders externes qui remplacent entièrement le rendu des nuages restent
à vérifier séparément ; aucune compatibilité non testée nest promise.
## Archives
- [Sanctuary-beta.077.mrpack](../build/Sanctuary-beta.077.mrpack), 9608669 octets.
SHA-256 : `1c3839f086e9545c920e4ebfc13f99a25d0684fa6f2dc8e1b2a842a8e7044045`.
- [Sanctuary-Test-beta.077.mrpack](../build/Sanctuary-Test-beta.077.mrpack), 9627602 octets.
SHA-256 : `1b0c302b9d4d5f59d5e954adbecde4acbacd9409c8cd6a743ccbe116826d7c0a`.
+36
View File
@@ -0,0 +1,36 @@
# beta.078 — Combats / Battles
Branche `codex/battle-menu-beta078`. Minecraft 26.3-pre-2 ; textures beta.070.
Le bouton et le titre « Duels et arènes » deviennent **Combats** en français
et **Battles** en anglais. Le lien depuis les duels de familiers utilise
également ce libellé. Les annonces dinscription indiquent le menu pause,
et les résultats renvoient au nouveau nom pour récupérer les gains.
Le [plan des menus](menu-architecture.md) conserve cette appellation. Le reste
de cette réorganisation reste une proposition ; l’économie est différée.
Aucune règle de combat, sauvegarde ou transaction nest modifiée.
## Vérification
`check build assemblePack assembleTestPack` réussi en 2 min 33 s, avec le
serveur dédié exclu conformément au refus de son EULA. Après la précision
du créateur sur le pluriel, les ressources et packs sont reconstruits en
8 s ; vérification finale des quatre traductions par langue et de leurs
paramètres de formatage.
Les 1 693 classes du JAR sont identiques à beta.077. Les textures, données
de jeu et le resource pack restent inchangés. Les deux archives embarquent
le JAR vérifié ; les archives beta.077 sont conservées à lidentique.
Reçu : `build/battle078-artifact.json`. Journaux : `build/battle078-check.log`
et `build/battle078-assemble.log`.
Pas de nouveau parcours client graphique pour ce changement de libellés.
Aucun déploiement dans une installation personnelle.
## Archives
- [Sanctuary-beta.078.mrpack](../build/Sanctuary-beta.078.mrpack).
SHA-256 : `9adfa5b5b8d49ed10943121ac2a2561d880886925a5f76db5ba98f0377d8d7e3`.
- [Sanctuary-Test-beta.078.mrpack](../build/Sanctuary-Test-beta.078.mrpack).
SHA-256 : `92bc1d59723546c31a6e7476dd9f53225c7957e5180091dfeaeade69ff265a86`.
+98
View File
@@ -0,0 +1,98 @@
# beta.074 — autonomie et ordres des familiers
Branche `codex/familiar-autonomy-beta074`. Implémentation et parcours client natif vérifiés.
## Comportements
| Personnalité | Décision autonome |
| --- | --- |
| Agressif | Engage les monstres quil voit à 12 blocs du joueur, accompagne ses attaques et riposte. |
| Protecteur | Garde 6 blocs autour du joueur, intercepte en priorité les monstres qui le ciblent, défend le joueur et lui-même. Abandonne la poursuite au-delà de 10 blocs du point gardé. |
| Passif | Nengage pas de combat spontanément ; défend le joueur attaqué et riposte sil est frappé. |
| Pacifiste | Évite le combat et s’écarte dun agresseur. Attend un ordre dattaque ou de garde. |
La vue est celle du familier : son propriétaire na pas besoin de voir le
monstre ou de le viser. Les animaux paisibles, les monstres neutres non engagés,
les alliés et les autres joueurs ne sont pas attaqués par la détection de
proximité. La riposte PvP et les ordres conservent les permissions du monde,
les factions et les règles des duels/arènes.
Un ordre dattaque prime sur la personnalité et les autres menaces. « Protéger »
ordonne explicitement de garder un allié visé, soi-même par défaut, ou le point
visé en maintenant Maj. Même un pacifiste peut exécuter cette garde demandée.
Pendant une poursuite spontanée, le protecteur peut changer de cible pour
intercepter une menace qui commence à viser son joueur ou lallié gardé.
Une cible morte laisse place à une nouvelle décision, au plus tard lors du
prochain balayage. Le protecteur revient près de son point de garde.
Les espèces de soutien disposent du coup de défense lorsquelles sengagent ;
leurs signatures, leurs dégâts existants et leurs coûts en fournitures restent inchangés.
## Commandes
H bref : attaquer lennemi visé, ou revenir au suivi sil ny a pas de cible.
H maintenu environ 0,4 seconde : ouvrir le menu dordres. La touche reste
configurable ; F échange les mains, G déclenche la technique. Relâcher après
louverture du menu ne lance pas dattaque. Les messages brefs confirment
lordre. « Suivre » suspend les réactions spontanées pendant cinq secondes.
## Exécution et conservation
Une recherche dentités chargées par seconde, décalée entre les familiers.
Aucun chargement de chunk ; chemins demandés au maximum toutes les dix ticks.
Une cible autonome perdue de vue cinq secondes, ou sans progression six
secondes, est écartée temporairement pendant dix secondes. Les commandes
explicites peuvent la désigner de nouveau. Les montures, familiers portés,
œufs rappelés et mode travail nentament pas de recherche autonome.
Le tirage de personnalité, les UUID, les 88 profils, la santé, lXP et les
souvenirs sont conservés. Aucun format de sauvegarde, terrain ni journal de
mise ne change. Les règles de combat des arènes restent prioritaires.
Textures beta.070, Minecraft 26.3-pre-2 et dépendances identiques.
## Vérifications
Le parcours `Autonomy074ClientChecks` réussit en **1 min 44 s** avec un
client et son serveur intégré, dans un nouveau monde plat de développement :
- Les quatre personnalités : recherche à 12/6 blocs, attaque du propriétaire,
dégâts réellement reçus par le joueur et déplacement jusqu’à la cible.
- Protecteur villageois : interruption dune poursuite pour une menace visant
le joueur, dégâts sans bouclier, cible suivante et retour dans la zone.
- Abeille neutre paisible puis agressive, garde explicitement demandée à un
pacifiste et priorité dune cible ordonnée.
- Vue propre au familier, obstacle opaque, cible enfermée, permissions,
mode travail, suspension puis reprise après Suivre.
- Événements clavier natifs : H bref/maintenu, reconfiguration sur Y,
absence dattaque au relâchement du menu, F natif ; fiches FR/EN.
La régression `Familiar071ClientChecks` réussit en **1 min 50 s** :
352 000 identités sur les 88 profils, réactions, dégâts, araignées montées,
Strider sur lave et les deux Nautilus aux tailles 2 500 et 6 000 ‰.
La fermeture/réouverture du monde conserve lUUID et le caractère.
Journaux : `build/autonomy074-client.log`, `build/autonomy074-mounts.log`.
Captures de la fiche actuelle et des montures : `build/autonomy074-evidence/`.
`Arena073ClientChecks` et la régression `Duel054Checks` passent en
**1 min 10 s** : arènes de familiers/joueurs/mixtes, 25 inscrits simulés,
consentement PvP, K.-O., morts réelles, mises, remboursements et récupération
après interruption. Journal : `build/autonomy074-arenas.log`.
`check build assemblePack assembleTestPack` réussit en **3 min 6 s**
(124 tâches). Les deux exports packwiz sont vérifiés : **1 689 classes**
comparées au JAR, sources testées identiques, huit libellés FR/EN mis à jour,
ressources et textures beta.070 intactes. Seules les quatre classes sources
prévues changent par rapport à beta.073. Les neuf archives précédentes
beta.070 à beta.073 restent strictement identiques.
Reçu : `build/autonomy074-artifact.json`.
Ces essais couvrent lIA commune ; ils ne constituent pas un nouveau playtest
des 88 techniques ni un test à plusieurs clients distants. Le serveur dédié
reste exclu (`-x :sanctuary:runGameTest`) conformément au refus daccepter
son EULA ; les essais natifs utilisent le serveur intégré.
Aucun monde personnel ni déploiement Prism.
## Archives locales
- [Sanctuary-beta.074.mrpack](../build/Sanctuary-beta.074.mrpack), 9595113 octets.
SHA-256 : `cc73e93c2ae17d6b52b6c8dd53639771645c5e5becd848e2c76765e32c919c5d`.
- [Sanctuary-Test-beta.074.mrpack](../build/Sanctuary-Test-beta.074.mrpack), 9614048 octets.
SHA-256 : `0851e10350788b76efa96ec1a50db951a74200213399e2a4216f8d5b1b8fafcb`.
+80
View File
@@ -0,0 +1,80 @@
# beta.067 — touche du familier et dragon volant
Branche `codex/familiar-controls-beta067`.
## Contrat
La touche des ordres du familier passe de H à F. La touche G de technique
reste indépendante. Le réglage H hérité est déplacé une seule fois vers F
au démarrage ; les autres touches personnalisées restent conservées. Un
marqueur local `config/sanctuary-familiar-key-v1.txt` permet ensuite de choisir
à nouveau H sans être remappé au démarrage suivant. L'action reste configurable.
Pendant le jeu Sanctuary, ouvrir les ordres avec F ne déclenche plus l'échange
des objets des deux mains. Le raccourci natif garde son comportement dans les
inventaires et hors Sanctuary ; réassigner les ordres libère également F.
Le modèle natif du dragon utilise un axe avant opposé aux modèles vivants
ordinaires. Son historique de vol reçoit cette correction d'orientation ;
la direction physique du familier et des attaques reste inchangée.
Le petit dragon porté utilise le planeur existant : descente bornée à
0,12 bloc par tick, distance de chute accumulée effacée. Le grand dragon porté
(taille existante > 1 200 et < 2 500 millièmes) permet une montée maintenue avec
la touche de saut, bornée à 0,18 bloc par tick. Relâcher reprend la descente
planée. Le seuil colossal de 2 500 reste une monture sur laquelle le joueur
s'assoit ; il ne peut pas être porté sur la tête. Seul le dragon reçoit la
montée lorsqu'il est porté ; les autres petits animaux volants gardent leur
plané et les montures colossales leur pilotage existant.
La physique du joueur reste native avec collisions. Le serveur n'exempte du
contrôle d'immobilité aérienne que la montée réellement soutenue par un grand
dragon porté et la touche de saut reçue. Retrait du familier, immersion,
déconnexion et perte du portage interrompent l'assistance. Les règles
existantes de collision du passager s'appliquent au dragon sur la tête.
Aucun changement du format des œufs, des sauvegardes ou de la génération.
Les indications de montée dans l'infobulle utilisent la touche de saut
configurée, avec libellés FR/EN.
## Vérification
`Familiar067ClientChecks` réussit en 55 secondes sur un monde plat de
développement avec serveur intégré :
- F ouvre réellement les ordres sans changer les objets des deux mains ; H
ne les ouvre plus. Réassigner sur K fonctionne et libère l'échange natif F.
- La position de la tête dans le modèle de dragon soumis au rendu est devant
le corps pour quatre directions cardinales, pas derrière.
- Dragons de tailles 350 et 650 : Maj + clic droit natif, passager synchronisé,
descente bornée côté client, distance de chute effacée des deux côtés ;
Espace ne permet pas de monter avec ces petits.
- Taille 1 500 : montée de plus de huit blocs en maintenant Espace pendant
cent ticks, connexion maintenue, reprise du plané au relâchement, collision
au plafond sans traversée du joueur.
- Taille 3 500 : le joueur monte sur le dragon, puis décolle avec Espace.
Le plané des petits existait déjà : le parcours le confirme sur le dragon.
Le nouveau comportement de montée concerne la catégorie « grand » portée.
La transparence de proximité existante reste conservée.
Journaux : `build/familiar067-client.log` et `build/familiar067-check.log`.
L'attente initiale de tous les chunks du rayon échouait dans le parcours ;
le test attend désormais la partie jouable et utilise sa zone de test locale.
`./gradlew check build assemblePack assembleTestPack` réussit en 2 min 12 s
avec `-x :sanctuary:runGameTest -PsanctuaryClientTests=true
-PsanctuaryQuickTests=true`. Le parcours natif est lancé séparément avec
`-PsanctuaryFamiliar067ClientTests=true -PsanctuaryClientNoVsync=true`.
Les deux MRpacks contiennent les 1 646 classes compilées et leurs ressources
vérifiées. Les différences avec beta.066 se limitent aux cinq classes du
correctif, au mixin enregistré, à l'indication FR/EN et aux versions des mods.
JEI, les textures, les données de génération et les archives beta.066 restent
identiques. Reçu : `build/familiar067-artifact.json`.
[Pack normal](../build/Sanctuary-beta.067.mrpack) ·
[Monde plat rapide](../build/Sanctuary-Test-beta.067.mrpack).
Validation en client natif avec serveur intégré, sans serveur dédié ni essai
à plusieurs clients distants. Aucun canal publié, instance personnelle ou
monde existant modifié.
+55
View File
@@ -0,0 +1,55 @@
# beta.069 — retour des ordres du familier sur H
Branche `codex/familiar-orders-h-beta069`.
Les ordres du familier reviennent sur **H**. **F** conserve l’échange natif des
objets entre les mains ; **G** reste la technique du familier. Les touches
restent configurables dans les options et les libellés FR/EN existants suivent
le raccourci assigné.
Le remplacement introduit en beta.067/.068 est annulé au premier démarrage :
une ancienne affectation F des ordres devient H. Les autres touches
personnalisées sont conservées. Le nouveau marqueur local
`config/sanctuary-familiar-key-v2.txt` rend cette opération unique, même si le
marqueur v1 existe déjà. Les modifications manuelles ultérieures ne sont pas
réécrites. Le filtrage qui consommait les actions du raccourci d’échange des
mains est supprimé. Aucun changement du serveur ou des sauvegardes.
## Vérifications natives
Le parcours `Familiar067ClientChecks`, actualisé pour le retour sur H, réussit
en **1 min 7 s** : H ouvre les ordres sans échanger les mains ; F échange un
diamant et un lingot dor dans les deux sens sans ouvrir de menu ; G conserve
son affectation par défaut ; une personnalisation des ordres sur K fonctionne.
Le reste de ce parcours existant (orientation, plané et vol du dragon) passe
également. Le test utilise un monde plat de développement et un serveur intégré.
Après le parcours, les options enregistrées sont bien H/F/G et le marqueur v2
contient beta.069 (`build/familiar069-controls.json`). Le lanceur réinitialise
la configuration de test ; ce parcours ne prétend donc pas valider la mise à
jour complète dune ancienne installation. La conversion conditionnelle F→H
et le garde par marqueur ont été vérifiés dans le code ; la configuration
personnelle de Prism na pas été modifiée.
Journal : `build/familiar069-client.log`. Le sélecteur de ce parcours reste
`-PsanctuaryFamiliar067ClientTests=true`, avec `-PsanctuaryClientTests=true
-PsanctuaryQuickTests=true -PsanctuaryClientNoVsync=true`.
## Livraison locale
`check build assemblePack assembleTestPack` réussi en **2 min 13 s**, 122 tâches,
avec exclusion du serveur
dédié (`-x :sanctuary:runGameTest`) et les profils client/test rapide.
Voir `build/familiar069-check.log`. Aucun serveur dédié ni acceptation dEULA.
Les 1 647 classes du mod correspondent à la compilation. Seule la classe
`FamiliarClient` change par rapport à beta.068 ; textures, traductions,
chargement, dragon et JEI restent identiques. Les archives beta.068 gardent
leurs empreintes. Reçu : `build/familiar069-artifact.json`.
- [Pack normal](../build/Sanctuary-beta.069.mrpack), 5726225 octets.
SHA-256 : `1c923452ff4d5d186309390fac3306b8e05162143c852619b7519741002aa165`.
- [Monde plat rapide](../build/Sanctuary-Test-beta.069.mrpack), 5745158 octets.
SHA-256 : `bed499fa29f4247a9e321207df503b3147b8b4fc8c8541d817cdfe2a2db9a945`.
Aucune instance personnelle ni canal public modifié.
+169
View File
@@ -0,0 +1,169 @@
# beta.071 — personnalité et engagement des familiers
Branche `codex/familiar-personality-beta071`.
## Comportement
Chaque œuf individualisé possède une personnalité de combat, visible dans
son infobulle et sa fiche de familier. Les ordres restent sur H, la technique
sur G et l’échange des mains sur F.
| Personnalité | Réaction spontanée | Poursuite | Cadence dinitiative |
| --- | --- | --- | --- |
| Agressif | Engage les monstres proches, accompagne les attaques et riposte | ×1,55 | délai ×0,90 |
| Protecteur | Accompagne les attaques et défend le joueur, lallié protégé et lui-même | ×1,45 | délai habituel |
| Passif | Riposte seulement lorsquil est frappé | ×1,35 | délai ×1,05 |
| Pacifiste | S’éloigne du danger, sans attaque automatique | ×1,35 sur ordre | délai habituel |
**Tous obéissent à un ordre explicite dattaque**, y compris le pacifiste.
Une autre menace ne remplace pas une cible désignée tant quelle reste valide.
Un Enderman ou un Piglin zombifié non engagé est laissé tranquille ; les
familiers et les joueurs ne déclenchent pas cette recherche de proximité. Le PvP conserve ses règles et les
duels leur consentement. Suivre suspend les réactions automatiques cinq
secondes ; Rappeler ou le mode travail interrompent lengagement spontané.
Les vitesses sont des multiplicateurs de navigation : la poursuite utilisait
auparavant ×1,15. Lapproche gagne donc environ 17 à 35 % selon le caractère,
sans changer la vitesse de promenade, celle des montures ni les dégâts.
Les attaques à distance gardent leur distance utile. Les feintes au contact
cessent suffisamment tôt pour entrer réellement à portée. Les préparations
dattaque restent lisibles : leurs durées ne sont pas raccourcies.
Les espèces de soutien conservent leurs techniques signature. Un villageois
agressif, ou un soutien explicitement envoyé attaquer, dispose dun coup au
contact (2 dégâts de base, préparation 0,25 s, intervalle 2 s), sans exiger un
bouclier dans linventaire du joueur. Les techniques offensives natives des
espèces restent utilisées.
## Individus et tendances
Le tirage utilise lUUID déjà enregistré et lidentifiant despèce. Sa fonction
et son sel sont figés dans `FamiliarPersonality`. Il ne dépend ni du nom, ni
de la taille, ni de lXP, ni du propriétaire ou de la reconnexion. Les anciens
œufs identifiés reçoivent donc un caractère stable sans conversion de fichier.
Un œuf sans identité le découvre à sa première individualisation habituelle.
| Tendance despèce | Agressif | Protecteur | Passif | Pacifiste |
| --- | --- | --- | --- | --- |
| Vindicateur et espèces farouches listées dans le code | 70 % | 20 % | 7 % | 3 % |
| Villageois et espèces paisibles listées dans le code | 5 % | 15 % | 25 % | 55 % |
| Autres espèces et futurs profils sans règle spécifique | 25 % | 40 % | 25 % | 10 % |
Toutes gardent des exceptions. Le tempérament historique hors combat
(curieux, calme, joueur), les souvenirs, les identifiants, les 88 profils et
les schémas de sauvegarde restent intacts. Changer ces seuils ultérieurement
changerait le caractère d’œufs existants : cela demanderait un contrat de
migration, pas une simple retouche d’équilibrage.
## Araignées
Laraignée et laraignée venimeuse utilisent la navigation descalade native.
En monture colossale, avancer contre une paroi les fait grimper à environ
quatre blocs par seconde. Relâcher ou s’écarter permet de redescendre. La
vérification porte sur le corps de laraignée : un plafond touché par le joueur
ne déclenche pas une ascension. Les collisions du corps et de la tête du
cavalier restent actives. Un espace vide nautorise aucune montée.
L’éligibilité à la monte reste celle des familiers colossaux ; les petits
familiers se portent selon les règles existantes.
## Effets des montures natives
Audit du code de Minecraft **26.3-pre-2** : `AbstractHorse` et ses variantes,
`Camel`/`CamelHusk`, `Pig`, `Strider`, `HappyGhast` et
`AbstractNautilus`/`Nautilus`/`ZombieNautilus`. Les sources de comparaison
sont extraites du JAR utilisé par le build dans `build/native071-source/`.
| Espèces natives pouvant recevoir un cavalier | Effet transmis au cavalier | Trait de déplacement |
| --- | --- | --- |
| Cheval, âne, mule, cheval squelette, cheval zombie | Aucun effet d’état natif | Déplacement et saut terrestres |
| Lama, lama de marchand | Aucun | Montables, mais pas pilotables en vanilla |
| Dromadaire, dromadaire momifié | Aucun | Déplacement terrestre ; dash natif distinct dun effet d’état |
| Cochon | Aucun | Direction à la carotte sur bâton en vanilla |
| Strider | Aucun bonus de résistance au feu au joueur | Appui à la surface de la lave |
| Ghast joyeux | Aucun | Vol |
| Nautilus, Nautilus zombie | **Souffle du Nautilus** | Nage et dash natifs |
Le Souffle du Nautilus est ajouté au moment de monter et entretenu tant que
le joueur reste sur son Nautilus colossal. Il utilise leffet Minecraft
original : durée de 60 ticks, renouvellement toutes les 40 ticks, niveau zéro.
Lair restant est figé sous leau, sans être rempli comme avec une potion.
Après la descente, leffet expire naturellement sous trois secondes ; il
nest pas supprimé brutalement au risque deffacer une autre source.
Il fonctionne pour les deux espèces, sur toute la plage colossale existante
(2 500 à 6 000 ‰, y compris les plus grands « titans »), sans achat de
technique ou condition de personnalité. Porter simplement l’œuf ou le petit
familier sur sa tête ne donne pas cet effet de **cavalier**.
Le Strider retrouve le support de collision de lave et la remontée natifs,
avec une infobulle dédiée. Il maintient son cavalier au-dessus de la surface ;
ce nest pas une immunité personnelle à la lave après avoir sauté du Strider.
Les autres espèces nappliquent pas de potion native à leur cavalier : aucune
na été inventée. Les commandes Sanctuary de ces montures restent communes,
comme demandé lors de leur introduction. Cet audit des traits ne porte pas
les selles, harnais, coffres d’équidés, sièges supplémentaires, charges de saut
ou commandes de dash propres aux interfaces natives. Les pouvoirs familiers
existants restent disponibles selon leurs règles.
## Coût et validation
La recherche agressive est locale, dans les entités déjà chargées à huit
blocs du propriétaire, une fois par seconde, décalée entre familiers. Aucun
chargement de chunk. La navigation reste limitée à une demande de chemin
par dix ticks. Les décisions et les dégâts font autorité côté serveur.
Le parcours `Familiar071ClientChecks` réussit en **2 min 23 s** dans un
nouveau monde plat de développement, avec le client et son serveur intégré :
- 352 000 UUID répartis entre les 88 espèces ; quatre caractères possibles,
tendances villageois/vindicateur, stabilité après changement dXP et de souvenirs.
- Quatre personnalités de loups : réactions aux événements, ordre explicite,
navigation jusqu’à la cible et dégâts réels. Verrouillage de combat maintenu
même pour un familier qui refuse lengagement spontané.
- Villageois agressif : initiative spontanée et dégâts sans bouclier ; la cible
manuelle garde sa priorité. Fiche FR/EN et nom du familier inspectés.
- Araignée et araignée venimeuse colossales : touches natives, ascension du mur,
collision du cavalier avec le plafond et absence de montée dans lespace vide.
- Strider colossal : déplacement avec les touches natives à la surface dun
bassin de lave, cavalier hors de la lave et sans potion de résistance au feu.
- Nautilus et Nautilus zombie : tailles 2 500 et 6 000 ‰, cavalier effectivement
immergé, air figé à 40 pendant 90 ticks, renouvellement de leffet, expiration
et reprise de la consommation après la descente. Équiper l’œuf seul ne le donne pas.
- Fermeture puis réouverture réelle du monde : même UUID et même personnalité.
Les captures sont dans `build/familiar071-evidence/`, le journal dans
`build/familiar071-client.log`. La matrice de personnalité couvre les 88
profils ; elle ne constitue pas un nouveau playtest de toutes leurs techniques.
Pas de validation à deux clients distants ou sur serveur dédié dans cette passe.
La construction de la distribution est effectuée dans
`build/familiar071-workspace/` : les sources de la release beta.070, puis les
changements ciblés de la 71. Cette copie évite de compiler les fichiers de
statues de la beta.072 ajoutés simultanément dans le dossier partagé.
`build/familiar071-source-snapshot.json` fixe les empreintes de cette copie.
Les sources courantes du familier conservent les mêmes changements.
`check build assemblePack assembleTestPack` réussit en **5 min 50 s**,
124 tâches (108 exécutées, 16 à jour), dans cette copie isolée. Le serveur
dédié `:sanctuary:runGameTest` reste exclu conformément au refus daccepter
son EULA ; le parcours natif utilise uniquement le serveur intégré du client.
Les deux exports MRpack sont vérifiés : version exacte, dépendances figées,
JAR comparé aux **1 651 classes compilées** et aux ressources traitées,
égalité normal/Test sauf module de test, absence de classes de test dans le
mod livré. Seuls les neuf fichiers Java prévus et douze libellés par langue
changent par rapport à beta.070 ; les schémas, le catalogue, JEI, le code de
Demeure et les 150 fichiers du resource pack restent identiques. Le manifeste
de Demeure suit seulement le nouveau numéro du pack.
Reçu et empreintes : `build/familiar071-artifact.json`.
- [Pack normal](../build/Sanctuary-beta.071.mrpack), 9 462 323 octets.
SHA-256 : `1ab36663fe24313597c5328170a3eb2ca2e33bfdfc4c6fbc85c1f5c03102628c`.
- [Monde plat rapide](../build/Sanctuary-Test-beta.071.mrpack), 9 481 258 octets.
SHA-256 : `06309f4cd382053fb42d80c3f131152932e07523ace799c50d1d899026029a92`.
Le resource pack demeure beta.070, actif par défaut et désactivable. Aucun
monde personnel, aucune instance Prism et aucun canal public ne sont modifiés.
+67
View File
@@ -0,0 +1,67 @@
# beta.079 — ordres et défense des familiers en solo
Branche `codex/familiar-solo-beta079`, Minecraft 26.3-pre-2, textures beta.070.
Signalement : les familiers paraissent passifs en solo ; H ouvre le menu,
mais les ordres ne semblent pas exécutés. Espèces citées : slime, loup, poule.
## Causes reproduites
Louverture du menu H envoie `refresh`. Cette lecture consommait la même
limite de fréquence que les ordres : un clic immédiatement après louverture
était rejeté sans message. La mise à jour possède désormais sa propre limite,
sans affaiblir celle des actions. Le test natif échouait sur Rappeler avant
cette correction et réussit ce passage après séparation des requêtes.
Suivre suspendait pendant cinq secondes toutes les réactions automatiques,
y compris la défense du joueur attaqué. Le délai ne concerne désormais que
les initiatives spontanées. Une personnalité qui défend son propriétaire
ou riposte peut réagir immédiatement aux dégâts. Le pacifiste conserve son
comportement ; lordre explicite de protection reste prioritaire.
Les ordres du menu confirment leur réception : suivi, attaque, protection
ou rappel. Une attaque sans familier disponible, sur une cible refusée ou
avec un familier porté/monté donne une explication au lieu dune confirmation
dattaque inexécutable. FR/EN disponibles. H bref et H maintenu sont conservés.
Le mode utilitaire continue de suspendre les attaques autonomes. La santé,
les UUID, les personnalités, les capacités et les sauvegardes sont conservés.
Les nouveaux compteurs de requêtes sont temporaires côté serveur ; aucune
migration ni modification dun monde personnel.
## Vérification
Deux reproductions natives avant correction : rejet de Rappeler juste après
louverture de H ; absence de défense dun loup passif juste après Suivre.
Le scénario utilise un nouveau monde plat solo de développement, sans
commandes, sans droits opérateur et sans achat du Lien actif.
`Solo079ClientChecks` réussit en **1 min 53 s** : rappel immédiatement après
louverture de H, retour au suivi, garde du joueur, attaques ordonnées et
défense immédiate après Suivre pour le loup, le slime et la poule. Les tests
attendent le déplacement et la frappe, contrôlent la baisse de santé et
identifient le familier comme auteur du dégât, pour exclure le soleil ou les
autres dégâts environnementaux. Les confirmations sont reçues côté client.
`Autonomy074ClientChecks` réussit en **1 min 58 s** : quatre personnalités,
garde, vue et obstacles, cibles neutres, priorité des ordres, permissions,
mode utilitaire, délai avant les poursuites spontanées, H bref/maintenu,
touche reconfigurée et échange des mains avec F. Fiches FR/EN vérifiées.
Journaux : `build/solo079-client-final.log`, `build/solo079-regression.log`.
Captures : `build/solo079-evidence/`.
`check build assemblePack assembleTestPack` réussit en **2 min 21 s**,
124 tâches, avec `-x :sanctuary:runGameTest` conformément au refus du serveur
dédié. Les 1 693 classes du JAR correspondent au build ; seules
`FamiliarBattle` et son état temporaire changent par rapport à beta.078.
Deux nouveaux libellés par langue, ressources et textures inchangées.
Sources vérifiées, même JAR embarqué dans les deux packs ; archives beta.078
conservées à lidentique. Reçu : `build/solo079-artifact.json`.
Aucun déploiement personnel ; les essais utilisent le serveur intégré.
## Archives
- [Sanctuary-beta.079.mrpack](../build/Sanctuary-beta.079.mrpack).
SHA-256 : `7df042d720e2511310387183efcc432cafec4786623e530e5552fd2ba36ca7db`.
- [Sanctuary-Test-beta.079.mrpack](../build/Sanctuary-Test-beta.079.mrpack).
SHA-256 : `0ca193b2918c7d650503c2ce2fa97d2b3c626fee5ddd99df0cf51ea05a2bb82e`.
+81
View File
@@ -0,0 +1,81 @@
# beta.062 — soleil et chapeaux des familiers
Branche `codex/familiar-sunlight-beta062`.
Le familier reprend la sensibilité solaire du type natif de son œuf : tag
`minecraft:burn_in_daylight`, avec respect de l'immunité native au feu. La
vérification utilise le moteur Minecraft 26.3-pre-2 : lumière, ciel dégagé,
eau/pluie/neige poudreuse et attribut environnemental `MONSTERS_BURN`.
Les bébés familiers concernés brûlent aussi. Les autres espèces conservent
leur comportement actuel.
Tout objet équipé dans l'emplacement de tête Sanctuary protège des nouvelles
inflammations solaires, sur un mob ordinaire comme sur un familier. Maj + clic
droit avec un objet l'équipe ; Maj + clic droit à main vide le récupère.
L'armure de tête native conserve sa protection et son usure habituelles quand
aucun chapeau Sanctuary n'est équipé. Un chapeau ne rend pas résistant au feu :
une flamme déjà allumée termine sa durée native, ou s'éteint dans l'eau.
Les familiers sensibles perdent leur santé de combat en brûlant, jusqu'au K.-O.
habituel, sans détruire l'œuf. Leur feu reste dangereux pendant un duel ; les
attaques extérieures au duel restent exclues. Les flammes suivent la taille
visible du compagnon. Une indication FR/EN sur les œufs concernés explique le
besoin d'un chapeau, sans achat préalable d'une aptitude.
Aucun identifiant ni format de sauvegarde ne change. Le chapeau utilise
l'équipement existant : il est rendu au sol quand le familier disparaît et
n'est pas stocké dans son œuf. Aucun monde existant ni canal n'est modifié.
## Vérifications
`Sunlight062ClientChecks` passe dans le client natif 26.3-pre-2, sur un monde
plat de développement neuf :
- 88 œufs exposés au moteur solaire natif : zombies, villageois zombies,
noyés, chevaux zombies, nautiles zombies, squelettes, vagabonds, embourbés
et phantoms brûlent ; les autres conservent leur immunité, dont le Wither
squelette naturellement résistant au feu ;
- mob ordinaire avec pierre, diamant, bâton et torche en chapeau, protection
et usure du casque natif conservées, reprise du feu après retrait ;
- vrai paquet Maj + clic droit pour équiper puis retirer un diamant de la
tête du familier, synchronisation de l'objet sur son modèle côté client ;
- santé persistée qui baisse sur les vrais ticks de feu, feu déjà allumé qui
expire sous un chapeau, absence de nouvelle inflammation ;
- toit, nuit, pluie et immersion ; l'eau éteint aussi les flammes existantes ;
- K.-O. déclenché par un tick natif de brûlure, identité et œuf conservés ;
- brûlure et fin normale d'un duel au K.-O., refus des coups directs d'un
joueur extérieur et des autres dégâts environnementaux pendant le duel ;
- flammes synchronisées aux dimensions visibles du familier bébé, capture
inspectée dans `build/sun062-evidence/sunburn.png`, indication FR/EN.
Journal `build/sun062-client.log` : **55 s**, marqueurs
`SUNLIGHT062_SPECIES_PASS` et `SUNLIGHT062_PASS`. La première tentative de
lancement a été arrêtée au blocage OpenGL de synchronisation verticale ;
le parcours réussi utilise `-PsanctuaryClientNoVsync=true` et le réglage VSync
uniquement dans le dossier du client de développement.
L'essai de duel utilise deux entités serveur avec une connexion simulée pour
la seconde ; aucun essai avec deux clients humains n'est revendiqué. La
capture inspectée est celle du zombie bébé ; le rendu individuel des huit
autres espèces sensibles n'a pas été revu visuellement.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest
-PsanctuaryClientTests=true -PsanctuarySunlight062ClientTests=true
-PsanctuaryQuickTests=true` réussit : **122 tâches**, **2 min 10 s**.
Le parcours client est exécuté séparément par `:sanctuary:runClientGameTest`
avec les mêmes propriétés et `-PsanctuaryClientNoVsync=true`.
Aucun EULA de serveur dédié n'a été accepté.
## Archives locales
- [Pack normal](../build/Sanctuary-beta.062.mrpack), 5678508 octets.
SHA-256 : `3eb3729023bcb1f6931198d50694ecac1fee1c660aeb0cf1edc9776aebbfe2fa`.
- [Monde plat rapide](../build/Sanctuary-Test-beta.062.mrpack), 5697441 octets.
SHA-256 : `f71dd47ef228d2acab99fe9b7b58e11d4f39234f926d7a313956bdd69e9cd2b6`.
Les **1624 classes** embarquées correspondent au build. Le reçu
`build/sun062-artifact.json` vérifie le contenu des archives, les différences
ciblées avec beta.061, les dépendances imbriquées et la conservation des
archives précédentes. JEI, la génération et les autres ressources du pack
restent identiques. Aucun canal publié, instance personnelle ni monde
existant n'est modifié.
+60
View File
@@ -0,0 +1,60 @@
# beta.085 — textures du Fourneau
Les trois images originales de 48 × 48 pixels fournies par le créateur sont
conservées, octet pour octet, dans `assets/sanctuary/textures/block/fourneau/`.
Chaque image couvre une face entière du cube de trois blocs de côté. Les UV
des 27 composants prélèvent chacun un tiers de l'image, à la densité native
de 16 pixels par bloc. Façade dans l'orientation choisie à l'assemblage ;
côtés et arrière avec `side`, dessus et dessous avec `top`.
## Contrat de compatibilité
Aucun changement au schéma 1 de `sanctuary:multiblocks`, aux identifiants des
blocs ou aux données de leurs inventaires. Les assemblages beta.084 utilisent
leur orientation déjà enregistrée. Un four isolé garde le modèle de son pack
de ressources. Les nouveaux modèles ne s'appliquent qu'aux fours assemblés.
Dissocier ou casser un composant rétablit les modèles ordinaires ; cuisson,
combustible et objets restent dans les BlockEntities vanilla.
Le serveur transmet seulement l'apparence des machines dans les chunks
suivis par le joueur : à l'envoi natif du chunk, à l'assemblage et à la
dissociation. Aucun paquet par tick de cuisson. Le client invalide les
sections concernées et utilise les modèles de blocs natifs via Fabric,
avec éclairage, occultation des faces et destruction habituels. Il efface
son cache au déchargement d'un chunk, au changement de monde et à la
déconnexion. Les modèles sont rebâtis lors du rechargement des ressources.
Les modèles JSON se régénèrent avec `python3 tools/generate-fourneau-models.py`.
Les textures originales ne sont pas générées. Le pack intégré beta.070
reste inchangé : ces nouveaux assets appartiennent au mod beta.085.
## Limites visuelles
Aucune variante allumée n'a été fournie : l'image reste celle du créateur,
y compris pendant la cuisson. L'état lumineux natif du Fourneau est conservé.
Le Fût conserve son apparence de barils dans cette livraison.
## Vérifications et livraison
- `check build assemblePack assembleTestPack` : tâches réussies dans
`build/fourneau085-check.log`. La première extension du test visuel de chauffe
surveillait par erreur la case de diamants, qui ne cuit pas ; son attente a
échoué après les tâches de construction. Le scénario a été corrigé pour
observer le compartiment contenant le minerai, sans changement du code livré.
- `Fourneau085ClientChecks` relancé avec succès : nouveau monde plat intégré,
quatre orientations, 27 modèles répartis sur quatre chunks, état allumé,
rechargement des ressources, rejet d'un paquet d'une autre dimension,
déchargement réel puis retour, dissociation, casse et conservation des
17 diamants stockés. Journal final : `build/fourneau085-client-final.log`.
- Contrôle des trois PNG à l'octet près, des 108 modèles et de leurs UV,
des sources embarquées, des archives et de la préservation des assets
antérieurs. Reçu : `build/fourneau085-artifact.json`.
- Captures natives : `build/fourneau085-screenshots/`, dont
`0001_fourneau085-north.png` et `0006_fourneau085-lit.png`.
Archives locales vérifiées : `build/Sanctuary-beta.085.mrpack` et
`build/Sanctuary-Test-beta.085.mrpack`. Les archives beta.084 restent inchangées.
Le canal packwiz et l'instance Prism n'ont pas été déployés. Aucun monde
personnel modifié, aucune EULA acceptée, aucun serveur dédié lancé : le test
utilise uniquement le serveur intégré au client. Le rendu avec deux clients
LAN distincts et avec un moteur de shaders tiers reste à éprouver séparément.
+60
View File
@@ -0,0 +1,60 @@
# beta.088 — Navigation du Fût sans recentrage de souris
Branche : `codex/fut-navigation-beta088`.
## Problème et correction ciblée
Changer de page ou appliquer une recherche remplaçait le menu serveur via
`openMenu`. Minecraft envoyait d'abord une fermeture au client, puis une
ouverture. Cette fermeture reprenait la souris pour le jeu et la recentrait,
obligeant à revenir sur le bouton à chaque clic.
La transition nettoie désormais l'ancien menu côté serveur avec
`doCloseContainer`, sans envoyer l'écran de fermeture intermédiaire. Le
nouveau menu est ensuite ouvert normalement : la souris reste libérée et
conserve sa position. Cela concerne la pagination et la recherche du Fût,
ainsi que les onglets du Fourneau qui utilisent la même navigation.
Chaque page garde un nouvel identifiant de menu : les paquets de clics ou de
navigation de la page précédente sont toujours rejetés. Les contrôles de
portée, d'accès, de structure, d'objet porté au curseur et la limite de
fréquence restent en place. La fermeture réelle du coffre reste native.
Aucun changement de stockage, de sauvegarde, de textures ou de libellés.
## Vérifications
Le parcours natif existant a été étendu avec un contrôle des paquets réseau
sur un second joueur de test : sans correction, la navigation envoie un
`ClientboundContainerClosePacket` et le test échoue. Avec la correction,
aucune fermeture intermédiaire, et une fermeture réelle envoie toujours
son paquet. Ce contrôle reste valable si la fenêtre de test est inactive.
Côté client, le test place réellement le curseur sur le bouton puis clique
trois fois sur Suivant et deux fois sur Précédent sans le déplacer. Sa
position est vérifiée après chaque page, après une recherche localisée en
français et après chacun des trois onglets du Fourneau. Échap ferme le menu
normalement côté client et côté serveur.
Les essais existants passent également : 729 cases, deux joueurs utilisant
le même stock, objets au curseur, identifiants périmés, transfert rapide,
hoppers, démontage, casse, persistance et cuisson native partagée.
Aucun serveur dédié lancé, aucune EULA acceptée, aucun monde personnel touché.
Journaux : `build/fut088-before-client.log` (régression reproduite) et
`build/fut088-client.log` (succès en 39 secondes). Le parcours reste activé
par `-PsanctuaryMultiblocks084ClientTests=true` avec
`-PsanctuaryClientTests=true -PsanctuaryQuickTests=true`.
`check build assemblePack assembleTestPack` réussit en 2 min 23 s
(124 tâches, 103 exécutées), avec le GameTest dédié explicitement exclu.
Journal : `build/fut088-check.log`. Le reçu `build/fut088-artifact.json`
vérifie les sources, le JAR, les archives normal/Test et la conservation de
tous les assets beta.087. Seules `MultiblockMenu` et sa classe interne de
transfert changent dans les classes compilées (décalage des lignes de debug
pour cette dernière).
Livraison locale : `build/Sanctuary-beta.088.mrpack` et
`build/Sanctuary-Test-beta.088.mrpack`. Les archives beta.087 restent intactes.
Aucun déploiement dans l'instance Prism ni sur le canal publié. Un essai LAN
avec deux clients distincts reste à réaliser ; le test d'accès partagé utilise
un second joueur serveur dans le client de développement.
+51
View File
@@ -0,0 +1,51 @@
# beta.086 — textures du Fût
Le Fût assemblé reçoit les trois images originales de 48 × 48 pixels :
côtés, dessus et dessous. Chacune couvre une face entière de trois blocs
sur trois, avec 16 pixels par bloc et sans retouche de l'image.
## Contrat avant implémentation
Aucun changement de sauvegarde : les 27 barils et leurs 729 cases restent
vanilla, avec le même registre d'appartenance et les mêmes données. Seul le
modèle affiché change quand le Fût est assemblé. Les barils indépendants
conservent le modèle du pack de ressources actif. Dissociation et casse
rétablissent leur apparence. Les textures du Fourneau beta.085 sont conservées.
Le Fût ajoute un paquet client `sanctuary:fut_appearance_v1` ; le paquet
du Fourneau reste inchangé. Les deux utilisent la même mise en cache visuelle
par chunk et le même rendu natif. Aucun paquet supplémentaire par tick,
aucun stockage parallèle d'objets. Le pack intégré beta.070 reste inchangé ;
les textures du Fût appartiennent aux assets du mod beta.086.
## Rendu et vérifications
Les modèles JSON se régénèrent avec `python3 tools/generate-fut-models.py`.
Les PNG sources sont conservés dans `assets/sanctuary/textures/block/fut/`.
Aucune variante ouverte n'a été fournie : le Fût conserve le même dessus
pendant l'accès à son inventaire.
`Fut086ClientChecks` a réussi dans un nouveau monde plat intégré : Fût et
Fourneau voisins, modèles distincts, raccords des côtés/dessus/dessous,
ouverture et prélèvement dans les 729 cases, rechargement des ressources,
déchargement réel puis retour dans quatre chunks, dissociation et casse.
Les objets conservés restent dans les mêmes barils physiques ; retirer la
façade du Fût ne retire pas celle du Fourneau voisin.
Journal du client : `build/fut086-client.log`. Captures natives :
`build/fut086-screenshots/`, notamment `0001_fut086-top-side.png` et
`0002_fut086-bottom-side.png`. Le Fût est suspendu dans cette scène de test
pour que le dessous puisse être inspecté.
`check build assemblePack assembleTestPack` a réussi en 2 min 36 s
(124 tâches, 104 exécutées), sans serveur dédié ; journal
`build/fut086-check.log`. Le reçu `build/fut086-artifact.json` vérifie
l'identité des PNG, les 27 modèles, les sources embarquées et les archives,
ainsi que la conservation de tous les assets beta.085 du Fourneau.
Livraison locale : `build/Sanctuary-beta.086.mrpack` et
`build/Sanctuary-Test-beta.086.mrpack`. Les archives beta.085 restent
immuables. Aucun déploiement sur Prism ou sur le canal publié, aucun monde
personnel modifié, aucune EULA acceptée. Les vérifications multijoueur
avec deux clients distincts et avec un moteur de shaders tiers restent
à effectuer séparément.
+77
View File
@@ -0,0 +1,77 @@
# Synchronisation Git jusqu’à beta.092
Ticket du 16 septembre 2026, branche `codex/git-sync-beta092`.
Le dépôt distant était resté sur [beta.061](git-sync-beta061.md). Cette
synchronisation enregistre les sources, ressources, outils et comptes rendus
locaux jusqu’à la livraison **beta.092**, pour **Minecraft 26.3**. Elle inclut
le commit déjà local du resource pack beta.070. `main` avance directement et
le tag exact `beta.092` désigne les sources de la livraison vérifiée.
Le compteur reste à beta.092 : ce rattrapage najoute aucun changement de jeu.
Les documents de conception restent des intentions, selon leurs contrats.
Les anciens tags et les posts de release existants sont conservés. Les versions
intermédiaires ne reçoivent pas artificiellement un tag pointant sur beta.092.
## Sources et vérifications
Une copie des 2 636 fichiers sources non ignorés est figée dans un worktree
isolé, sans modifier les fichiers de travail. Les JAR générés, archives ZIP,
MRpack, sauvegardes et dépendances téléchargées restent ignorés ; seul le
wrapper Gradle est versionné. Les références courantes des README sont mises
à jour, avec ce compte rendu.
Les journaux et le reçu de la [livraison beta.092](multiblock-pick-beta092.md)
confirment :
- `./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussi en **3 min 1 s**, **124 tâches**.
- Parcours client natif `Pick092ClientChecks` réussi en **1 min 19 s**, avec
`PICK092_PASS` : **864 états**, douze combinaisons normal/gluant et six
directions, vrais clics molette, inventaire de survie, parties mobiles et
Ctrl + clic sans données techniques.
- Les empreintes des deux MRpack locaux correspondent au reçu de livraison.
La copie destinée à Git est reconstruite sous Java 25 avec :
```sh
./gradlew assemblePack assembleTestPack \
-x :sanctuary:check -x :demeure:check \
-x :jei:check -x :sanctuary-test:check
```
Réussite en **48 s**, **23 tâches**. Les contrôles déjà réussis sur la livraison
ne sont pas relancés pour cette copie. La vérification des manifestes packwiz
et des ressources graphiques passe pendant lassemblage. Les quatre JAR
reconstruits sont **identiques octet pour octet** à ceux de la livraison :
| JAR | Entrées | Classes |
| --- | ---: | ---: |
| Sanctuary beta.092 | 2 960 | 1 753 |
| Demeure beta.092 | 24 | 14 |
| Sanctuary Test beta.092 | 25 | 3 |
| JEI 30.32.0-sanctuary.3 pour 26.3 | 1 378 | 1 101 |
Les 811 fichiers Java des trois modules Sanctuary, Demeure et Test correspondent
également aux archives de sources de la livraison. Les ressources, métadonnées
et dépendances imbriquées sont incluses dans la comparaison des JAR.
SHA-256 du JAR Sanctuary :
`8eb91eb41d0236ac6d6d064e9638c99802d28a2d83b8aef5ff1291c9495bcc8c`.
Reçus locaux ignorés : `build/git-sync-beta092-verification.json`,
`build/git-sync-beta092-assemble.log`, `build/pick092-artifact.json`,
`build/pick092-check.log` et `build/pick092-client.log`.
## Limites et distribution
Les essais beta.092 utilisent un client macOS et son serveur intégré ; pas de
session LAN à deux clients. Le serveur GameTest dédié est exclu et aucune EULA
nest acceptée. La dernière exécution générale consignée dans le rattrapage
[beta.060](git-sync-beta060.md#vérifications) comptait 235 réussites sur 246,
avec 11 échecs à trier ; elle ne constitue pas une validation de beta.092.
Cette synchronisation ne prétend pas résoudre ces échecs ni valider Windows/Linux.
Les sources et leur tag sont publiés sur Git. Les archives bêta restent locales,
le canal packwiz reste sur alpha.30.7 et aucune instance Prism, sauvegarde
personnelle ou installation de serveur nest modifiée.
+65
View File
@@ -0,0 +1,65 @@
# beta.066 — cosmétiques animés sans doublon
Branche `codex/cosmetic-render-beta066`.
## Contrat
Un coffre porté n'affiche qu'un coffre : son couvercle s'ouvre et se ferme
sans laisser un second coffre fermé dessous. Une cloche portée conserve
son support et une seule cloche qui oscille. Le même correctif s'applique
aux modèles partagés avec les mobs portant un cosmétique.
Minecraft 26.3 distingue la géométrie du bloc posé de son modèle isolé.
Ce dernier peut déjà inclure un coffre fermé, une cloche fixe ou un livre.
Sanctuary dessinait ce modèle isolé puis le rendu animé de l'entité de bloc.
Le rendu avec une entité de bloc utilise désormais la géométrie native du
bloc posé pour ses parties fixes, avec les teintes et la lumière du bloc,
puis l'entité de bloc pour les parties animées.
Les panneaux conservent leur modèle isolé : Sanctuary soumet séparément
leur texte, pas une deuxième planche. Les bannières et les têtes conservent
leur rendu personnalisé ; les lits et portes gardent leurs deux moitiés.
Le feu de camp conserve sa base et les aliments synchronisés. Aucun asset
n'est remplacé. Aucun changement serveur, d'interaction, de stockage,
de sauvegarde ou de génération.
## Vérification
Le parcours `Cosmetic066ClientChecks` crée un monde plat de développement,
utilise les modèles et le collecteur de rendu natifs, et reçoit les événements
du serveur intégré. Avant correction, le test reproduit deux soumissions
`ChestModel` pour un seul cosmétique ouvert. Après correction, les
contrôles n'en comptent plus qu'une.
Le parcours client réussit en 40 secondes :
- coffres normal, piégé, de l'Ender et Shulker : une seule représentation,
ouverture et fermeture issues des événements serveur ;
- cloche : un seul modèle animé, un support fixe, oscillation puis arrêt ;
- feu de camp : base conservée et aliment reçu du serveur visible ;
- table d'enchantement : un seul livre sur sa base ; panneaux, bannière,
tête de joueur, lit complet, porte complète, four et étagère conservés ;
- douze cas de coffres/cloches sur poules, vaches et zombies adultes ou bébés.
Le test compte les soumissions réelles de modèles, pas seulement la présence
d'un état d'animation. Les captures avant/après du coffre et celle de la cloche corrigée ont
été relues dans `build/cosmetic066-evidence/`.
Journaux : `build/cosmetic066-before.log`, `build/cosmetic066-client.log`.
`./gradlew check build assemblePack assembleTestPack` réussit en 2 min 22 s
avec `-x :sanctuary:runGameTest -PsanctuaryClientTests=true
-PsanctuaryQuickTests=true`. Le parcours client dédié utilise aussi
`-PsanctuaryCosmetic066ClientTests=true -PsanctuaryClientNoVsync=true`.
Les tests avec serveur dédié restent exclus ; la validation des animations
utilise le serveur intégré du client.
Les deux archives sont vérifiées contre les 1 645 classes et les ressources
compilées. Seul `HeadCosmeticModels` change dans les sources principales
par rapport à beta.065. Les textures, JEI et les archives beta.065 restent
identiques. Le code de Demeure reste identique ; sa version suit le pack.
Reçu : `build/cosmetic066-artifact.json` ; build : `build/cosmetic066-check.log`.
[Pack normal](../build/Sanctuary-beta.066.mrpack) ·
[Monde plat rapide](../build/Sanctuary-Test-beta.066.mrpack).
Archives locales, sans publication du canal ni déploiement personnel.
Une session avec plusieurs clients distants n'a pas été relancée.
+68
View File
@@ -0,0 +1,68 @@
# beta.068 — animation discrète du préchargement
Branche `codex/loading-animation-beta068`, ticket LOAD-03.
## Comportement
Une ligne de neuf glyphes SGA, encadrée de crochets ASCII, accompagne les
étapes réelles de préparation. Elle utilise la police denchantement native
`minecraft:alt` : les lettres de Sanctuary, avec une lumière grise/blanche
qui parcourt la ligne dans les deux sens en 3,2 secondes. Le fond uni et les
textes Minecraft restent sobres. Aucun pourcentage fictif ni nouvelle texture.
Lanimation dépend de lhorloge monotone lue pendant chaque rendu, pas des
ticks : elle continue pendant les longs calculs dune même phase. Les glyphes
sont préparés une seule fois, sans sondage de terrain ni travail serveur.
La même ligne accompagne ensuite le téléchargement natif du terrain, sous la
carte des chunks lorsquelle existe. La carte et la barre de progression de
Minecraft conservent leur rendu et leur comportement. Si une interface trop
petite ne laisse pas de place sous les chunks, lindicateur est masqué plutôt
que de recouvrir la progression. Les transports par portail sont hors de ce
changement. Lintroduction longue, sa musique, le flash et Hello World restent
inchangés, comme le cache dérivé et les étapes publiées par le serveur.
## Vérifications natives
Client natif 26.3-pre-2 réussi en **41 s**. Huit captures pendant une phase
identique, avec la méthode `tick()` de l’écran volontairement inactive. Les
sept comparaisons de pixels successives diffèrent uniquement dans la ligne
de glyphes ; le titre, le fond et l’étape restent identiques.
Inspection FR/EN dans la fenêtre native 854 × 480, réglages GUI 2/3/4 avec
le plafonnement automatique de Minecraft à la taille disponible. La carte des
chunks et sa barre native restent visibles ; le message générique hors
préparation garde son rendu habituel. Ces écrans de test utilisent des états
fixes, sans créer de monde ni simuler un pourcentage dans le code livré.
Les caches et les cycles de démarrage nont pas été modifiés ni remesurés ici.
Preuves locales : `build/loading068-client.log`, les captures et la comparaison
`build/loading068-evidence/animation-check.json`. Le test ne lance aucun serveur
et ne demande aucune acceptation dEULA.
```sh
JAVA_HOME=/Users/koka/Library/Java/JavaVirtualMachines/temurin-25.jdk/Contents/Home \
./gradlew :sanctuary:runClientGameTest -x :sanctuary:runGameTest \
-PsanctuaryClientTests=true -PsanctuaryLoading068ClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryClientNoVsync=true
```
## Livraison locale
`check build assemblePack assembleTestPack` réussi en **2 min 15 s**, 122 tâches,
avec `-x :sanctuary:runGameTest -PsanctuaryClientTests=true
-PsanctuaryQuickTests=true`. Aucun serveur dédié lancé.
Les **1 647 classes** de larchive correspondent à la compilation. Seuls
`LoadingGlyphs`, `PreloadScreenMixin` et `PreloadTerrainScreenMixin` changent
par rapport à beta.067. Intro, Hello World, cache, logique serveur, textures,
sons, traductions et JEI restent identiques. Les archives beta.067 gardent
leurs empreintes. Reçu : `build/loading068-artifact.json`.
- [Pack normal beta.068](../build/Sanctuary-beta.068.mrpack), 5726390 octets.
SHA-256 : `cfb55d414186e6be5f0e19770049158302d4fbf37c0fa696306acda428594a2e`.
- [Monde plat rapide beta.068](../build/Sanctuary-Test-beta.068.mrpack), 5745324 octets.
SHA-256 : `643913caf577ee61b23747f378e9a21f5b1a54531901791d47d8ad0a75531dff`.
Aucun déploiement dans Prism, aucune publication du canal et aucun monde
personnel modifié.
+138
View File
@@ -0,0 +1,138 @@
# beta.064 — cache des plans et préchargement natif
Branche `codex/loading-cache-beta064`. Reprend l'audit beta.063 et conserve les
travaux préexistants (HUD du resource pack et documentation des capes inclus).
## Contrat du cache dérivé
Le cache est facultatif et reconstructible dans `cache/sanctuary-plans-v1/`
sous le dossier du monde. Il ne change aucun chunk, aucun journal d'expansion,
aucune donnée d'habitant ni les paramètres du générateur. Un ancien monde sans
cache suit exactement le calcul existant et remplit le cache ; supprimer ce
répertoire rétablit le même calcul. Pas de migration de sauvegarde nécessaire.
Les clés comprennent la graine, le générateur et ses paramètres, l'option
structures, les versions des mods et toutes les couches de ressources de
génération (`worldgen`, `structure`, `tags`, `dimension`, `dimension_type`) et
les modèles NBT locaux de `generated/`. Les
plans structurels incluent aussi la liste des îles concernées. Un changement
invalide le cache ; une entrée illisible, incomplète ou dont l'empreinte diffère
est ignorée puis recalculée. Écriture temporaire, synchronisation et renommage
atomique. Aucun objet Minecraft vivant, monde, chunk ou état RNG n'est sérialisé.
Les index dérivés sont reconstruits à partir des géométries validées.
Le cache couvre l'hydrologie régionale (eau, terrasses, écoulements et lave),
les traversées, salles souterraines, sanctuaire minier, ruines et patrimoine de
la racine, ainsi que les pièces natives du village, des mines, des temples et
des ruines asséchées. Ces dernières utilisent le NBT natif de Minecraft, dont
la lecture doit restituer exactement la même écriture ; sinon elles sont
recalculées. Les pièces restaurées appartiennent au nouveau serveur, sans
réutiliser les objets du serveur précédent. Le lecteur vanilla des pièces jigsaw
recalcule leurs limites à partir du modèle et perd les extensions réservées aux
jonctions : le cache réapplique ces limites enregistrées, puis exige une égalité
NBT complète. Cette correction reste locale au lecteur du cache, sans mixin de
chargement des chunks ni modification des structures sauvegardées.
Les plans aériens conservent leur journal persistant existant, qui fait
également autorité. Les anciennes générations et les villes des expansions
conservent leurs planificateurs actuels ; la livraison cible les plans de
l'île Sanctuary actuelle (`generation24`, terrain alpha.30.7).
## Préchargement
Texte et étapes issus du travail réel du serveur intégré, fond uni et graphisme
Minecraft. Aucun portail du Nether dans cette préparation. L'introduction,
Hello World, sa durée, sa musique et le flash restent inchangés. Les étapes de
calcul n'affichent pas de pourcentage inventé ; Minecraft fournit sa progression
réelle des chunks dès qu'elle est disponible. La préparation locale affiche
les étapes publiées par le serveur intégré avant même l'admission réseau.
Un client distant ne reçoit pas de faux état d'un serveur qui n'a pas encore
ouvert sa connexion : il conserve les étapes natives du téléchargement.
Le transport dimensionnel par portail reste un écran Minecraft distinct.
Les libellés FR/EN suivent le travail : vérification/lecture du cache, eau et
lave, traversées, salles, sanctuaire minier, ruines, patrimoine, îlots aériens,
village, mines, temples, enregistrement et point d'arrivée. Deux lignes
centrées sur fond uni remplacent l'attente générique ; aucune texture ajoutée.
La progression native des chunks et ses conditions de fermeture restent gérées
par Minecraft. Aucun nouveau bouton d'annulation ne force un arrêt au milieu
d'un calcul ou d'une écriture.
## Vérification reproductible
Client natif 26.3-pre-2, serveur intégré uniquement, monde de développement
neuf : graine `42`, île moyenne de diamètre `724`, structures actives,
distances de rendu et simulation de `8`. Première arrivée avec les 29,5 secondes
dintroduction complètes. Ensuite réouverture du même monde, puis remplacement
dune seule entrée de cache par un contenu tronqué et nouvelle réouverture.
Le contrôle compare les géométries des plans, leau et la lave, les index par
colonne et par chunk, les écritures de blocs et de butin des renderers et une
grille de points de protection. La lecture des pièces natives exige une égalité
NBT complète après restauration. Les essais de fichiers couvrent labsence,
le changement de clé, une copie sous une mauvaise identité, un checksum faux,
une validation refusée et un gzip tronqué. Chaque refus retombe sur le calcul.
L’écran est rendu par Minecraft en FR/EN pour inspection visuelle. Les phases
observées pendant le véritable démarrage sont enregistrées indépendamment des
ticks du client, qui ne tournent pas pendant sa première boucle de chargement.
Mesure locale du 15 septembre 2026, même scénario que laudit beta.063 :
| Parcours | Durée jusquau jeu |
|---|---:|
| Première création, cache vide, introduction complète | 122,255 s |
| Réouverture avec cache | 2,126 s |
| Entrée structurelle corrompue, recalcul automatique | 39,177 s |
| Référence beta.063, réouverture sans cache | 58,701 s |
Il sagit dune mesure sur cette machine et cette graine, pas dune garantie de
durée. Le cache accélère surtout les réouvertures ; un monde neuf doit toujours
calculer ses plans et préparer ses chunks. Les mises à jour de version ou de
ressources invalident volontairement les anciennes entrées. La première
réouverture dun ancien monde sans cache sert à le remplir.
Réouverture : **3 lectures réussies, 0 échec, 0 écriture**. Récupération :
**3 lectures réussies, 1 échec attendu, 1 remplacement**. Comparaison identique
des **217 053 écritures de blocs/butin** et de 93 150 points de protection,
ainsi que des plans et des index fluides. Les 17 étapes utiles du scénario ont
été observées ; les libellés FR/EN et les captures natives sont vérifiés.
Preuves locales : `build/loading064-client.log`,
`build/loading064-evidence/timings.json` et captures dans le même dossier.
```sh
JAVA_HOME=/Users/koka/Library/Java/JavaVirtualMachines/temurin-25.jdk/Contents/Home \
./gradlew :sanctuary:runClientGameTest -x :sanctuary:runGameTest \
-PsanctuaryClientTests=true -PsanctuaryLoading064ClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryClientNoVsync=true
```
Le drapeau de test rapide facilite lautomatisation ; ce scénario réactive
explicitement lintroduction longue avant la validation de Hello World.
Aucun serveur dédié lancé, aucune EULA acceptée, aucun monde personnel modifié.
## Livraison locale
`./gradlew check build assemblePack assembleTestPack` réussi en **2 min 21 s**,
**122 tâches**, avec `-x :sanctuary:runGameTest -PsanctuaryClientTests=true
-PsanctuaryQuickTests=true`. Le parcours natif plat/classique, avec Sanctuary
activé/désactivé puis réouverture, passe ses **8 ouvertures** en 53 s
(`build/gameplay064-client.log`). Les tests dédiés exclus restent remplacés ici
par les essais du serveur intégré, conformément au refus daccepter leur EULA.
Archives comparées à beta.063 : **1 645 classes conformes à la compilation**,
ressources FR/EN, mixins, métadonnées, dépendances exactes et absence de tests
dans le mod. Les classes de Hello World, de lintroduction, les sons, les
textures, les données de génération et JEI sont inchangés. Les variantes
normal/Test diffèrent uniquement par le module de test rapide et leur nom.
Les anciens MRpacks beta.063 conservent leurs empreintes originales.
- [Pack normal beta.064](../build/Sanctuary-beta.064.mrpack), 5720330 octets.
SHA-256 : `c05b78e2caf27d52cb318979ee66e19e5bca9c90673f8868ae50f85f0e2ff2c0`.
- [Monde plat rapide beta.064](../build/Sanctuary-Test-beta.064.mrpack), 5739264 octets.
SHA-256 : `962800291875ab5bebda1abaedc798e7cf110ba0ef13ac168a0b85b3b2c19d24`.
Reçu : `build/loading064-artifact.json`. Aucun canal publié ni instance Prism
modifié. Les optimisations de sondages partagés et de parallélisation de la
première création proposées dans laudit restent des tickets ultérieurs.
+231
View File
@@ -0,0 +1,231 @@
# beta.063 — audit du chargement et règle de jeu Sanctuary
> Suite livrée dans [beta.064](loading-cache-beta064.md) : cache des plans et
> étapes réelles sur fond uni, sans le portail de la proposition ci-dessous.
> Laudit et les mesures beta.063 restent conservés ici.
Branche `codex/loading-audit-gameplay-beta063`, après beta.062 conservée.
## Contrat
L'audit mesure séparément le démarrage du client, la création du terrain,
Hello World, l'introduction et l'arrivée. Il compare une île neuve, sa
réouverture, un monde plat et un monde Minecraft classique, sans utiliser
de sauvegarde personnelle. L'habillage du préchargement est une proposition
à examiner, pas une optimisation de terrain déjà livrée.
La règle native `sanctuary:use_sanctuary`, « Utiliser Sanctuary », permet
de choisir les règles Sanctuary à la création d'un monde, indépendamment
de son générateur. Elle est activée par défaut dans le pack ; la désactiver
avant la création conserve les capacités ordinaires du joueur. Activée sur
un monde plat ou classique, elle apporte Hello World, la progression,
l'inventaire, les familiers, les factions et les systèmes qui en dépendent,
ainsi que les modes temporel et météorologique Sanctuary.
Cette règle ne change jamais le générateur, le spawn natif, les chunks ou les
structures du monde choisi. Les fonctions propres aux îles et aux expansions
restent réservées à ce terrain. Les ressources et ajouts autonomes du mod
restent chargés, y compris quand cette règle est désactivée.
Le choix est fixé pour la partie : les commandes et options d'une partie
déjà ouverte ne peuvent pas le basculer. Le serveur conserve le choix natif
dans `level.dat` ; les données existantes d'habitant continuent d'identifier
une partie Sanctuary lors des réouvertures. Pas de nouveau format propriétaire.
Les parties Sanctuary et le module plat de test antérieurs conservent leur
admission historique ; la protection existante des anciennes parties sans
progression reste appliquée. Aucun Overworld classique déjà joué n'est
converti automatiquement. Une conversion reste hors de cette livraison,
conformément à la précision de l'utilisateur.
## Mesures du 15 septembre 2026
Client natif Minecraft **26.3-pre-2**, Fabric **0.19.5**, API
**0.160.0+26.3**, Java 25 ; Mac Apple M1, huit processeurs logiques,
tas JVM 2 Gio. Une exécution par scénario dans le même client, dans cet
ordre, avec enregistrement JFR `profile`. Graine **42**, île Moyen **724**,
structures actives, distances de rendu et de simulation **8**. Les autres
applications du bureau restent ouvertes. Ce sont des observations locales,
pas une moyenne représentative ni une preuve de gain entre versions.
Le compteur commence à la demande de création/ouverture. « Jouable » signifie
joueur présent, écran de jeu actif et fondu d'arrivée terminé. Hello World est
validé automatiquement ; le temps passé à choisir son personnage est exclu.
L'introduction complète reste active, même sur les terrains Minecraft.
| Scénario | Hello World prêt | Fin de la séquence d'introduction | Jouable |
| --- | ---: | ---: | ---: |
| Nouvelle île Sanctuary | 74,292 s | 103,889 s | **122,101 s** |
| Réouverture de cette île | Déjà accompli | Non rejouée | **58,701 s** |
| Nouveau monde plat + Sanctuary | 0,929 s | 30,489 s | **33,246 s** |
| Nouveau monde classique + Sanctuary | 3,809 s | 33,458 s | **37,632 s** |
Le premier lancement du processus client jusqu'au début du parcours au menu
prend environ **15 s**, en plus de ces durées. Il utilise les ressources déjà
présentes sur cette machine : ce n'est pas une installation ou un cache disque
à froid. Le profilage commence aux scénarios monde ; ce démarrage client est
chronométré, mais pas attribué fonction par fonction.
## Ce qui se passe réellement
1. Le client charge les classes, ressources, textures et sons. L'enregistrement
des services Sanctuary n'est pas encore la planification complète des îles.
2. Après « Créer », le serveur intégré construit les niveaux. Dans
`ExpansionRuntime.register`, l'événement `ServerLevelEvents.LOAD` prépare les
plans du générateur : eau, traversées, réseaux souterrains, sanctuaire minier,
patrimoine, village et expéditions. Cette étape bloque l'accès à Hello World.
3. Le serveur choisit le spawn et prépare les chunks d'arrivée. La configuration
réseau ouvre Hello World puis l'introduction ; une partie de la préparation
du monde continue pendant ces **29,5 s** de cinéma.
4. La séquence se termine, mais le monde peut ne pas être prêt. L'écran blanc
couvre alors l'attente, puis le fondu rend le jeu visible. Sur l'île mesurée,
environ **18,2 s** séparent la fin de l'intro de l'état jouable.
La première planification de l'Overworld occupe environ **58,8 s** entre
`SERVER_STARTING` et la fin de son événement `LOAD`. À la réouverture, elle
occupe encore **56,8 s**. La majeure partie de ces plans est recalculée à chaque
nouvelle instance du générateur ; le journal aérien existant ne met pas tous
les autres plans en cache.
Le `Time elapsed` natif est trompeur pour cet audit : il annonce **434 ms** à
la réouverture alors que l'entrée prend **58,7 s**. Ce compteur démarre après
les calculs lourds. Il ne doit pas servir seul de mesure du chargement complet.
## Où part le CPU
Les journaux de cette réouverture attribuent **23,913 s** aux traversées,
**14,590 s** au patrimoine, **7,758 s** au sanctuaire minier et **2,999 s** au
village. L'hydrologie annonce **13,052 s**, mais elle est appelée pendant la
préparation des traversées : **ne pas additionner ces deux durées**.
Dans les échantillons JFR des méthodes Java en exécution à la réouverture :
| Méthode | Part des échantillons |
| --- | ---: |
| `SmearedPerlinNoise.addToVolume` | 37,81 % |
| `NoiseStack.Perlin.get` | 22,80 % |
| `GradientNoise.permute` | 8,51 % |
Ces trois fonctions de bruit totalisent **69,12 %** des échantillons. Ce n'est
ni une part exacte du temps mural ni un gain récupérable annoncé. Les pauses
GC totalisent **838 ms** sur le profil de réouverture, contre **1,71 s** pour
la création de l'île. La pression mémoire existe, mais elle n'explique pas
l'essentiel de cette attente ; augmenter seulement la RAM ne cible pas le
coût dominant identifié.
Les profils incluent quelques secondes de jeu et la fermeture du monde.
Une première tentative avec `waitForChunksDownload()` a attendu la complétion
du rendu au-delà de l'arrivée jouable ; elle est conservée à part et exclue
des résultats ci-dessus. Les assertions finales utilisent la présence du joueur,
l'écran natif et la fin du fondu, pas ce critère plus strict du banc de tests.
Sources : `ExpansionRuntime.java:35`, `SanctuaryChunkGenerator.java:93` et `:291`,
`ProgressionService.java:194`, `IntroScreen.java:51` et `:170`,
`IntroSpawnFlash.java:20`. Les appels répétés à `prepareTransit` sont gardés :
certains sont protégés et certains prennent en compte les liens du patrimoine.
L'audit n'en déduit pas qu'ils peuvent simplement être supprimés.
## Habillage proposé, après retour utilisateur
**Conserver toute l'introduction**, sa durée, sa musique au dévoilement du
portail et son flash. L'habillage proposé concerne uniquement l'attente qui
précède Hello World et la réouverture d'une partie.
Écran sobre proche de Minecraft : fond sombre uni, portail natif au centre,
texte Minecraft discret et bouton Annuler natif. Aucun grand logo ajouté,
aucune carte décorative, aucun encadrement ni panneau de diagnostic permanent.
Les textures du portail, de l'obsidienne, du bouton et la police bitmap de la
proposition proviennent des ressources locales de Minecraft 26.3-pre-2.
Le texte décrit l'étape réelle : ressources, calcul du terrain, préparation
des alentours. Une barre et un compte de chunks peuvent apparaître uniquement
lorsque leur total est connu ; les calculs de plans restent sans faux pourcentage.
Le compteur `86 / 225` de la maquette est un exemple d'état, pas une mesure.
Annuler demande un arrêt propre au serveur, sans quitter au milieu d'une
écriture. Son comportement réel devra être intégré et testé avec l'écran.
La proposition interactive a été simplifiée après rejet de la première
direction. **Elle n'est pas intégrée au rendu du jeu dans beta.063.**
## Prochains tickets proposés
| Priorité | Résultat vérifiable | Garde-fous / validation |
| --- | --- | --- |
| 1 — Cache des plans | Réouvrir une île en relisant ses plans dérivés validés | Clé graine, révision de génération, paramètres et configurations ; écriture atomique ; corruption ou cache absent → recalcul ; comparaison exacte des plans et chunks, sans régénérer de monde utilisateur. Contrat de cache à formaliser avant développement. |
| 2 — Préchargement natif | Afficher l'écran épuré dès le début de la création et à la réouverture | Phases instrumentées, fermeture propre, texte FR/EN, annulation et erreur visibles ; intro intacte ; mesures du coût de l'écran. |
| 3 — Réutiliser les sondages | Éviter les colonnes de terrain recalculées entre plusieurs planificateurs | Cache borné, mêmes entrées et mêmes valeurs, empreintes bit à bit sur plusieurs graines ; reprendre la méthode de `docs/initial-loading.md`. |
| 4 — Travail en parallèle | Réduire le chemin critique restant après les caches | Seulement sur données immuables ; aucun accès au niveau Minecraft depuis des threads arbitraires ; gains mesurés et déterminisme vérifié. |
Aucun gain de ces futurs tickets n'est compté comme livré. La génération et
ses paramètres sont inchangés dans beta.063. La proposition respecte le choix
de conserver l'intro ; l'attente blanche mesurée reste un constat, pas une
modification autorisée de cette séquence.
## Utiliser la règle de jeu
À la création d'un nouveau monde, choisir le terrain **classique** ou
**plat**, puis laisser **Utiliser Sanctuary : Oui** dans les règles de jeu,
catégorie Joueur. Hello World et les règles Sanctuary s'appliquent au terrain
natif. Les capacités ordinaires sont conservées avec **Non**. Ce choix ne
nécessite pas une installation du module de test rapide.
`/gamerule sanctuary:use_sanctuary` permet de consulter le réglage. Une tentative
de changement en jeu est refusée avec une explication FR/EN ; les autres règles
restent modifiables. La capture du choix se fait au chargement de l'Overworld :
dans cette version, `getGlobalGameRules()` n'est pas disponible avant sa création.
## Vérifications
- Audit client natif : île neuve, même île réouverte, plat et classique neufs,
intro complète ; `LOADING063_AUDIT_PASS`.
- Parcours fonctionnel client et serveur intégré : plat/classique × activé/
désactivé, chacun créé puis réouvert, soit **8 ouvertures** ;
`GAMEPLAY063_PASS`. Vérifie Hello World uniquement si activé, sa non-répétition,
le familier initial conservé, les 3/10 cœurs et 1/4 rangées selon le mode,
l'absence de session d'expansion sur terrain natif, le temps et la météo.
- Vérifie le refus des mutations en jeu des règles globales et du niveau,
le refus explicite de la commande et la modification d'une autre gamerule.
Libellé et description natifs testés en français et en anglais.
- La non-conversion des anciens mondes classiques repose sur la garde
d'admission examinée dans le code ; aucun monde personnel n'est ouvert.
Commandes reproductibles (Java 25) :
```sh
./gradlew :sanctuary:runClientGameTest -x :sanctuary:runGameTest \
-PsanctuaryClientTests=true -PsanctuaryLoading063ClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryClientNoVsync=true
./gradlew :sanctuary:runClientGameTest -x :sanctuary:runGameTest \
-PsanctuaryClientTests=true -PsanctuaryGameplay063ClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryClientNoVsync=true
```
Preuves locales ignorées : `build/loading063-audit.log`,
`build/loading063-evidence/timings.tsv` (extrait des événements du journal),
les deux profils JFR d'île et leurs vues CPU/GC dans ce même dossier,
`build/gameplay063-client.log`. Les JFR plat/classique du dossier temporaire
du runner ont été nettoyés au test suivant ; leurs chronométrages restent dans
le journal intégral. Archiver `mods/sanctuary/build/run/clientGameTest/loading063/`
avant de relancer un parcours pour conserver tous les profils.
`./gradlew check build assemblePack assembleTestPack` réussit en **2 min 22 s**,
avec `-x :sanctuary:runGameTest -PsanctuaryClientTests=true
-PsanctuaryGameplay063ClientTests=true -PsanctuaryQuickTests=true
-PsanctuaryClientNoVsync=true`. Les tests serveur dédiés restent exclus, selon
le refus antérieur d'accepter leur EULA ; les huit ouvertures ci-dessus utilisent
le serveur intégré au client natif et vérifient réellement les règles côté serveur.
Les deux archives locales sont vérifiées : dépendances exactes, ZIP, identité
des **1 628 classes compilées**, ressources, absence de classes de test, même
contenu normal/Test sauf le module rapide. Le JAR, les classes d'introduction,
les ressources et les sons non concernés sont comparés à beta.062 ; le terrain
et toute la séquence d'introduction restent identiques. Les anciens packs
beta.062 sont conservés avec leurs empreintes originales.
- [Pack normal beta.063](../build/Sanctuary-beta.063.mrpack), SHA-256
`1af1ff168f5cf97d7f44bb28398eb79efae8e1c4858f05d5b4291b1dfe48ad93`.
- [Pack Test beta.063](../build/Sanctuary-Test-beta.063.mrpack), SHA-256
`405a55e76233c8403f27320f104e09ee590cae652b97b4396dd6a5b1a7dd0531`.
Reçu `build/loading063-artifact.json`, build `build/loading063-check.log`.
Pas de déploiement Prism, de changement de canal ou de sauvegarde personnelle.
+136
View File
@@ -0,0 +1,136 @@
# Menus Sanctuary — organisation
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).
Le renommage Combats / Battles a été livré en [beta.078](combat-beta078.md).**
Objectif : retrouver une pause compacte, avec une rubrique par intention et
des sous-pages cohérentes. Conserver les composants Minecraft, les textures
existantes et le retour vers l’écran dorigine.
## Pause
Reprendre la partie occupe toute la première ligne. Les rubriques suivent :
| Colonne gauche | Colonne droite |
| --- | --- |
| Découvertes / Discovery | Progression / Progression |
| Monde / World | Habitant / Inhabitant |
| Factions / Factions | Combats / Battles |
Options et Options du monde restent dessous, puis Sauvegarder et quitter ou
Déconnexion. Les entrées séparées Progrès et Statistiques sont déplacées dans
Progression et Découvertes. Monde ouvre toujours directement la carte.
Combats regroupe duels et arènes et reste un accès direct, conformément au
choix déjà confirmé.
**Économie est différée à la demande du créateur : aucun bouton économique,
même désactivé, nest prévu dans cette première passe.**
## Découvertes : ce que je connais et ce que jai fait
Un seul bandeau de catégories accueille le catalogue et
les statistiques correspondantes :
| Catégorie FR / EN | Contenu cible |
| --- | --- |
| Général / General | Statistiques générales vanilla : temps, déplacements, morts et autres compteurs existants. |
| Blocs / Blocks | Catalogue de blocs découverts, avec les compteurs disponibles : minés, posés, etc. |
| Objets / Items | Objets connus, avec leurs compteurs disponibles : fabriqués, utilisés, récupérés, etc. |
| Créatures / Mobs | Créatures découvertes et statistiques vanilla de rencontres mortelles : tuées et morts causées. |
| Recettes / Recipes | Recettes révélées et collections qui permettent de les débloquer. |
Les statistiques dun objet ou dune créature sont accessibles dans sa fiche,
sans imposer douvrir un second catalogue. Les compteurs vanilla et Sanctuary
gardent leur sens et leur source ; ne pas inventer un compteur absent ou
additionner deux compteurs décrivant la même action.
À la première ouverture, conserver lentrée actuelle sur les blocs possédés.
Les filtres de connaissance et laptitude Catalogue restent en vigueur :
« Tout » signifie tout ce qui est déjà découvert, jamais les éléments inconnus.
Les statistiques ne deviennent pas une voie de contournement de ces règles.
Les outils opérateur restent invisibles et désactivés en mode normal.
La beta.083 réunit les cinq catégories. Objets reprend les découvertes et
les compteurs existants ; Créatures sappuie sur les victimes et les morts
causées. Les simples observations de créatures ne sont pas enregistrées dans
les sauvegardes actuelles et ne sont donc pas présentées comme connues.
## Progression : où je vais et ce que je débloque
- Le titre et le nombre de niveaux disponibles ont une ligne réservée.
- À gauche, un accès **Progrès / Advancements**, au-dessus des six compétences.
Il ouvre larbre Minecraft existant et conserve le suivi des objectifs.
- Sous cet accès, les compétences gardent leurs barres dXP, leurs coûts et
leurs commandes dachat. Le remboursement opérateur reste conditionné
aux permissions.
- À droite, les **Aptitudes / Abilities** restent à leur place, avec leur
propre défilement.
- **Prestige / Prestige** et **Historique / History** occupent une rangée
dédiée, indépendante du compteur dXP. Lhistorique conserve les achats,
remboursements et événements personnels existants.
- Factions disparaît de cette navigation. Les sous-pages Prestige et
Historique ne doivent pas recréer lancien trio PrestigeFactionsHistorique.
Larbre de Progrès conserve son écran complet : limbriquer dans la colonne
des compétences rendrait les deux illisibles. « Au-dessus des compétences »
désigne ici son accès, pas un arbre miniature.
### Chevauchement corrigé en beta.083
Les boutons Prestige, Factions et Historique occupaient la même ligne que
les niveaux disponibles. La beta.083 réserve une ligne au compteur, puis
une rangée à Prestige / Historique, avant les deux colonnes de contenu.
## Monde, Habitant, Factions et combats
**Monde** conserve laccès direct à la carte et ses réglages. **Habitant**
reste la fiche personnelle : nom, bio, prestige, faction, personnage et
familier. Le nom de faction peut ouvrir la même page Factions que la pause.
**Factions** devient une destination dédiée : ma faction, membres,
invitations, capacité, gestion et journal de faction. Sans faction, cette
page présente la création et les invitations reçues. La navigation ne change
ni les coûts ni les règles existantes : dix niveaux pour créer, deux places
initiales, extensions liées aux charges de prestige.
**Combats** conserve sa liste publique de duels et arènes, les inscriptions, règles et
paris actuels. Les mises en objets existantes ne nécessitent pas de créer
l’économie générale pour fonctionner.
## Idées économiques conservées pour plus tard
Le créateur souhaite à terme une entrée Économie rassemblant le marché,
la banque, la bourse, la loterie, les navets, le marché noir, les offres,
requêtes et contacts. Aucun de ces services nest implémenté par ce cadrage.
Proposition à reprendre ultérieurement : un tableau daffichage commun aux
missions, contrats, projets et annonces, relié aux habitants et aux factions.
Une fiche indique lauteur, le besoin, la récompense éventuelle et l’état ;
des liens ouvrent le même contenu depuis chaque rubrique concernée.
Courrier et contacts permettent de se répondre dans une présentation proche
des livres, panneaux et inventaires Minecraft. La texture de courrier
mentionnée par le créateur reste à retrouver ou recevoir avant intégration.
Ne pas fixer ici une nouvelle monnaie, un transfert automatique dobjets,
une banque sans risque ou les conditions dun contrat. La vision contient
déjà des intentions sur les trois gemmes, la mailbox, le coffre-fort et les
navets du dimanche ; elles devront être conciliées dans un ticket dédié.
## Première passe vérifiable
1. Réorganiser la pause et séparer Factions de la navigation personnelle.
2. Réserver une ligne aux niveaux disponibles ; placer laccès Progrès
au-dessus des compétences, en conservant les aptitudes à droite.
3. Rattacher les statistiques à Découvertes en conservant tous les accès
existants, puis unifier les catégories selon les données disponibles.
Contrôler en français et en anglais, aux échelles GUI 2 et 3, avec retour
Échap/Terminé vers le bon parent, textes longs, défilement, mode normal et
opérateur. Vérifier le suivi des Progrès, les achats et remboursements,
Prestige, invitations de faction et fermeture des arènes avant réponse réseau.
Les nouveaux regroupements ne modifient ni sauvegardes ni transactions.
Le cadrage initial était documentaire. Sa livraison de code, ses limites et
ses vérifications sont décrites dans [beta.083](menu-organization-beta083.md).
+40
View File
@@ -0,0 +1,40 @@
# beta.076 — navigation et pouvoirs sélectionnés
Branche `codex/menu-navigation-beta076`. Minecraft 26.3-pre-2 ; textures beta.070.
Le menu pause ouvre directement **Duels et arènes**. **Progression** contient
les accès **Prestige**, **Factions** et **Historique**. La carte reste accessible
par **Monde**. Les retours reviennent à l’écran dorigine.
Lhistorique regroupe les événements de prestige/faction et les achats ou
remboursements du cycle actuel, avec les aptitudes conservées. Cette dernière
liste était restée dans lancienne page Monde, devenue inaccessible depuis
louverture directe de la carte. Les achats des cycles terminés restent dans
larchive serveur : aucun nouveau format de sauvegarde ou réseau nest ajouté.
Les noms de faction invalides et invitations vides ne proposent plus un clic
inutile. Le serveur conserve toutes ses validations et les confirmations.
« Usages historiques » devient **Choisir le pouvoir utilitaire**. Le mode
sélectionné et son pouvoir sont visibles, le prérequis Lien actif est expliqué,
et le contrôle affiché suit la touche configurée. La sélection ne lance pas
le pouvoir : fermer le menu puis utiliser la touche (G par défaut). Les
messages des pouvoirs utilitaires retrouvent leurs traductions propres.
La dérogation opérateur existante est conservée.
Les arènes souvrent immédiatement avec un état de chargement. Une réponse
arrivée après fermeture ne rouvre plus l’écran.
## Vérification
Parcours client natif final réussi en 1 min 3 s : FR/EN, échelles 2/3, navigation,
fermeture avant réponse, achats, fondation à deux places, annulation et
confirmation de prestige, conservation de faction/aptitude, sélection utilitaire
et message de recharge via G. Correction dun rafraîchissement manquant lors
de la réception du déblocage Lien actif. Le défilement est conservé lors
dun changement de pouvoir et ce comportement est aussi vérifié.
Captures : `build/navigation076-evidence-final/`.
Ce lot est distribué avec beta.077 et sa couche de nuages, ajoutée à la
demande du créateur pendant cette passe. Aucun pack beta.076 publié. Le build complet et les deux archives beta.077
sont vérifiés ; voir [la livraison](cloud-vortex-beta077.md).
Aucun déploiement personnel. Pas de serveur dédié (EULA non autorisée).
+108
View File
@@ -0,0 +1,108 @@
# beta.083 — organisation des menus
Application du [plan des menus](menu-architecture.md), sur la branche
`codex/menu-organization-beta083`.
## Navigation
La pause conserve Reprendre, puis trois rangées de deux rubriques :
Découvertes / Progression, Monde / Habitant, Factions / Combats.
Options, Options du monde et la sortie restent natifs, ainsi que les
éventuels ajouts du serveur. Aucun accès Économie nest ajouté.
Progression réserve une ligne aux niveaux disponibles, puis une rangée
Prestige / Historique. Progrès ouvre larbre Minecraft complet au-dessus
des compétences de gauche ; les aptitudes gardent leur défilement à droite.
Les sous-pages Prestige et Historique ne proposent plus Factions.
Factions devient une entrée directe, avec gestion et journal de faction.
Les coûts, confirmations, invitations, permissions et transactions restent
inchangés. Le journal reprend les événements de faction autorisés par le
serveur, y compris les événements personnels des anciennes factions.
Lhistorique personnel conserve les achats, remboursements et événements
déjà disponibles.
## Découvertes et statistiques
Un bandeau unique donne accès à Général, Blocs, Objets, Créatures et Recettes.
Changer de catégorie conserve le même écran parent : Échap / Terminé revient
directement à la pause, sans retraverser les catégories visitées. Les fiches
de statistiques reviennent à leur liste.
- **Général** : tous les compteurs généraux natifs, avec leurs unités et
leur format Minecraft (temps, distances, etc.).
- **Blocs** : catalogue Sanctuary existant, par défaut sur Possédés,
avec recherche, filtres et compteurs existants.
- **Objets** : par défaut les objets portés, y compris extension et
accessoires. Laptitude Catalogue permet dafficher également les objets
déjà découverts. Une fiche présente fabrication, utilisation, casse,
récupération et abandon, chacun avec son compteur natif distinct.
Une recette révélée ne suffit pas à déclarer son résultat découvert.
- **Créatures** : espèces déjà tuées ou ayant tué le joueur, avec leurs
deux compteurs natifs. Les sauvegardes nont pas de registre persistant
des simples observations de mobs ; cette limite est indiquée dans la
page. Les créatures inconnues ne sont pas listées.
- **Recettes** : catalogue et collections existants, avec le même contrôle
daccès à laptitude et aux recettes connues.
Les statistiques sont demandées au serveur à louverture ou à lactualisation,
puis affichées après réception du paquet Minecraft. Aucun compteur nest
additionné à son équivalent Sanctuary. Aucun format de sauvegarde, règle
de gameplay ou identifiant existant ne change.
## Hello World et arrivée
Hello World dessine un fond uni `#171717`, identique au préchargement, sans
le panorama hérité de l’écran principal. À la réception de lintro après
Hello World, ce fond sassombrit progressivement jusquau noir en une
seconde. La séquence originale de 29,5 secondes commence ensuite intégralement.
La musique reste liée à lapparition du portail, et le flash darrivée reste
inchangé. Rejouer lintro en jeu conserve son départ noir habituel.
## Vérifications
`Menus083ClientChecks` est passé dans un client Minecraft 26.3-pre-2 natif :
- français / anglais, GUI 2 et 3, captures de la pause, des catégories,
de Progression, de Factions et des fiches ;
- six destinations de pause, accès direct à la carte et aux combats,
fermeture conservée si une réponse darène arrive après Échap ;
- catégories sœurs et retours vers leur parent ; objets portés avant
Catalogue, objets déjà découverts après achat, objets et mobs inconnus
toujours masqués ;
- clic réel sur un Progrès pour activer/désactiver son suivi, arbre complet
et retour dans Progression ;
- achat de compétence, remboursement opérateur du dernier coût, visibilité
des contrôles après activation/révocation native des commandes ;
- création de faction avec annulation puis confirmation, deux places,
journal de faction séparé des achats, historique avec remboursements ;
- passage de prestige avec conservation de la faction et des aptitudes,
remise des compétences à zéro, charge reçue une seule fois.
`Welcome083ClientChecks` est passé en 53 secondes. Les captures natives
confirment le gris uniforme aux quatre coins, puis les valeurs RVB neutres
23 → 19 → 12 → 4 → 0 pendant le fondu. Le test vérifie aussi lintro complète,
le départ musical au portail, le replay noir et la fin sans double validation.
La passe native des menus a duré 1 min 30 s.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
est passé en **2 min 38 s**, avec 124 tâches. Les sources du JAR et ses
1 706 classes correspondent aux sorties vérifiées. La comparaison avec
beta.082 confirme que seuls les écrans et leur intégration changent ; données,
textures, resource pack beta.070 et classes serveur sont conservés.
Archives locales vérifiées :
| Archive | SHA-256 |
| --- | --- |
| `Sanctuary-beta.083.mrpack` | `83b98db727665ab29fc3f0070f1616d781d2995efde1d2d344b50436251300ee` |
| `Sanctuary-Test-beta.083.mrpack` | `1aea33aecbf247088702a39229815a1365afa54b53ba91be06f70fb89e1e86d3` |
Le reçu est dans `build/menus083-artifact.json`, les journaux dans
`build/menus083-client.log`, `build/welcome083-client.log` et
`build/menus083-check.log`. Les archives beta.082 restent intactes.
Pas de déploiement dans une instance personnelle ni de publication du canal
packwiz dans cette tâche. Le GameTest serveur dédié reste exclu conformément
au refus précédent daccepter son EULA ; les tests avec serveur intégré
utilisent uniquement des mondes plats de développement.
+102
View File
@@ -0,0 +1,102 @@
# beta.090 — Passage à Minecraft Java 26.3
Ticket de migration du projet, branche `codex/minecraft-26-3-beta090`.
## Cible et contrat avant modification
Passage de `26.3-pre-2` à la version finale **26.3**, publiée le 15 septembre
2026. Cible Java 25, Fabric Loader 0.19.5 et Fabric API 0.160.5+26.3.
Sanctuary, Demeure, le profil Test et le fork JEI sont compilés pour la même
version exacte. Le fork JEI devient `30.32.0-sanctuary.3`, avec sa provenance
et son patch reproductible ; aucune substitution du catalogue Sanctuary par
le JEI standard.
Cette livraison change les binaires du projet et les archives locales. Aucun
monde existant ni instance personnelle n'est ouvert ou modifié. Pas de
régénération, d'expansion activée ni de migration des schémas Sanctuary.
Les identifiants restent stables. Les essais utilisent des mondes neufs et
les graines documentées des parcours ; les algorithmes de génération
Sanctuary ne sont pas modifiés. Les résultats de génération sur la nouvelle
version sont validés par les contrôles existants, sans garantir l'identité
binaire de tous les chunks générés par deux versions de Minecraft.
Le pack de textures reçoit une édition beta.090 pour déclarer la cible 26.3.
Le format reste 97.1 et les images existantes sont conservées ; le manifeste
et l'archive beta.070 restent immuables. Les anciens packs beta.089 restent
également immuables.
## Sources vérifiées
- [Sortie officielle de Minecraft Java 26.3](https://www.minecraft.net/en-us/article/minecraft-java-edition-26-3) : Java 25, données 121.0 et ressources 97.1.
- [Manifeste Minecraft officiel](https://piston-meta.mojang.com/mc/game/version_manifest_v2.json) : entrée release 26.3, métadonnées et empreintes Mojang.
- [Fabric Loader pour 26.3](https://meta.fabricmc.net/v2/versions/loader/26.3) : 0.19.5 stable.
- [Fabric API 0.160.5+26.3](https://modrinth.com/mod/fabric-api/version/0.160.5+26.3) : version déclarée pour 26.3, JAR et empreinte épinglés dans packwiz.
## Adaptations
- Les cibles Minecraft/Fabric sont communes à Sanctuary, Demeure, Sanctuary Test
et au fork JEI. Les métadonnées des JAR et les deux distributions reprennent
ces versions ; Fabric API est épinglé par URL et empreinte.
- Le bouton d'ouverture du dossier des plans utilise `Blaze3D.openPath` :
l'ancien `Util.getPlatform().openPath` n'existe plus dans la version finale.
- Le scénario client du catalogue utilise la règle actuelle « Allow Commands »
pour activer puis révoquer le mode opérateur. Sa vieille attente d'un second
bouton d'inspection est retirée ; aucune permission de production n'est changée.
- Le rapport TerrainAtlas récupère la version Minecraft réellement exécutée.
- Minecraft passe du DataVersion 5018 (pre-2) à 5023 (finale), protocole 777.
Les schémas Sanctuary restent inchangés. Les caches de plans dérivés incluent
déjà les versions des mods et de Minecraft dans leur identité : la nouvelle
version recalcule ses plans sans réutiliser un cache de l'ancien moteur.
## Vérifications
Les essais clients utilisent Minecraft **26.3** natif et des mondes de
développement neufs, graine **42**. Aucun serveur dédié n'est lancé et aucune
EULA n'est acceptée ; `:sanctuary:runGameTest` est explicitement exclu.
- Super pistons : états de collision client/serveur, six directions, versions
normale/gluante, course, charge 108/109, obstacles, transport, sauvegarde et
rechargement. Le correctif de collision beta.089 est conservé et retesté.
- Multiblocs : navigation du Fût et recherche avec souris immobile, onglets du
Fourneau, fermeture normale, stockage, cuisson et persistance. Le correctif
beta.088 ne réintroduit pas de paquet de fermeture entre deux pages.
- Catalogue JEI : découverte, familles de recettes débloquées par advancements,
permissions opérateur et révocation, transfert réel depuis la sixième rangée,
ressources FR/EN, prestige, mort puis rechargement du monde.
- Monde Sanctuary : génération neuve, graine 42, introduction complète, cache
écrit puis relu sans recalcul, corruption volontaire d'un cache de développement
puis reconstruction. Les 217 053 écritures de blocs contrôlées, plans, géométries
des fluides, butins et zones protégées restent identiques après les deux reprises.
Le parcours natif passe en 3 min 35 s. À titre d'observation locale, la création
avec introduction dure 147,4 s, la reprise avec cache 2,7 s et la reprise après
corruption 40,5 s ; ces parcours ne constituent pas un benchmark comparable.
Les journaux locaux des quatre parcours sont
`build/migration090-{piston,multiblocks,recipes,world}-client.log`.
## Assemblage vérifié
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe sous Java 25 : **124 tâches**, en 2 min 53 s. Les quatre parcours clients
séparés passent également. Les deux exports packwiz sont vérifiés après écriture.
- [Pack normal](../build/Sanctuary-beta.090.mrpack) — SHA-256
`4cbb9c06126874ce4c767b5fb6b3bf61c2903784e908f030ebbfbc662aac4dfc`.
- [Pack Test](../build/Sanctuary-Test-beta.090.mrpack) — SHA-256
`ffe9f70ec0f17d567d0c108478ed90915eb5d8cdc407be9ba39860dd87adfc05`.
- [Textures seules](../build/Sanctuary-Resource-Pack-beta.090.zip).
- [Reçu de vérification local](../build/migration090-artifact.json).
Les JAR imbriqués, leurs cibles Minecraft, les archives de sources et les versions
packwiz concordent. L'empreinte du JAR Fabric API téléchargé par Gradle correspond
à celle de la dépendance packwiz. Les images, modèles, données et identifiants du
JAR Sanctuary sont identiques à beta.089 ; seul le `pack.mcmeta` graphique change.
La seule classe de production Sanctuary différente est `PlansScreen`. Les archives
normal/Test beta.089 et les sources du manifeste graphique beta.070 sont conservées.
## Limites et distribution
Les clients testés sont natifs sur macOS, avec serveur intégré. Pas de validation
Windows/Linux ou LAN à deux clients pour cette migration, ni de conversion d'une
sauvegarde personnelle. Les archives locales n'avancent pas le canal packwiz et
ne mettent pas à jour une instance Prism existante.
+68
View File
@@ -0,0 +1,68 @@
# beta.092 — Clic molette sur les super pistons
Branche `codex/multiblock-pick-beta092`, Minecraft 26.3.
## Contrat
Le clic molette sur un super piston renvoie le piston Minecraft d'origine :
`minecraft:piston` pour le normal, `minecraft:sticky_piston` pour le gluant.
Cela couvre les neuf socles, toutes les têtes et les tiges, dans les six
orientations et à chaque profondeur de course. Une sélection reçue côté serveur
pendant une transition de mouvement retrouve aussi le composant source.
Les blocs techniques n'obtiennent pas d'objet dédié.
La sélection reste native : en créatif, un exemplaire ; en survie, sélection du
piston déjà présent dans l'inventaire, sans création d'objet. Ctrl + clic molette
ne copie pas les données techniques du contrôleur ou du piston mobile sur
l'objet ordinaire. Le comportement des autres blocs et charges mobiles vanilla
reste inchangé. Fourneau et Fût gardent leurs blocs source natifs, four et baril.
Aucun format de sauvegarde, identifiant, modèle, texture ou règle de génération
n'est modifié. Aucun monde existant n'est ouvert ou migré.
## Vérifications
Le parcours `Pick092ClientChecks` utilise un monde plat neuf de développement,
graine 42 : tous les états client/serveur, puis de vrais clics molette sur des
super pistons assemblés et étendus, en créatif et en survie.
Le parcours natif passe en **1 min 19 s** sur Minecraft 26.3/macOS :
- 864 états vérifiés côté client et serveur, avec et sans demande de données.
- Douze combinaisons normal/gluant × six directions ; vrais clics molette sur
chaque socle central, tête et les deux tiges du multibloc assemblé.
- Le résultat est exactement un piston natif, avec les composants ordinaires.
- En survie, un stack de cinq pistons présent est sélectionné sans duplication ;
inventaire vide, aucun objet n'est créé.
- Sélection des états mobiles d'extension/rétraction via le gestionnaire de paquet
serveur natif. Ctrl + clic ne transporte pas le NBT du contrôleur ou du mouvement.
- Les charges mobiles vanilla conservent leur comportement. Four et baril
conservent aussi leur sélection native.
```sh
./gradlew :sanctuary:runClientGameTest -x :sanctuary:runGameTest \
-PsanctuaryClientTests=true -PsanctuaryPick092ClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryClientNoVsync=true
```
Journal : `build/pick092-client.log`, marqueur `PICK092_PASS`.
## Livraison vérifiée
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **3 min 1 s**, 124 tâches. Journal : `build/pick092-check.log`.
Les exports correspondent aux JAR et à leurs sources. Textures, modèles,
données, JEI et archives beta.091 sont conservés à l'identique.
Reçu local : `build/pick092-artifact.json`.
- [Pack normal](../build/Sanctuary-beta.092.mrpack), SHA-256
`3c1907fb3427e3df421eb84eb8f05c2e89c7cb3d27ef30d856602559cd33a383`.
- [Pack Test](../build/Sanctuary-Test-beta.092.mrpack), SHA-256
`a8acaffc0b803b81e12da7439af24abfb6dadfe890a952b6e89916317e9f4914`.
## Limites
Tests sur client natif macOS avec serveur intégré de développement ; pas de
session LAN à deux clients pour cette livraison. Serveur GameTest dédié exclu,
aucune EULA acceptée. Aucun déploiement dans une instance personnelle ni
publication du canal packwiz.
+98
View File
@@ -0,0 +1,98 @@
# beta.084 — Clé dorée, Fût et Fourneau
Premier lot implémenté sur `codex/multiblocs-beta084`.
Référence historique lue, sans modification : `../26.2/anotherworld`,
`VaultStructure`, `SafeVaultMenu` et `SafeVaultScreen`. Reprise du repérage
borné autour d'un centre et du contrôle serveur, pas du stockage monétaire,
de la propriété privée ni de sa forme creuse à 26 composants.
## Contrat de sauvegarde et de migration — avant implémentation
Aucune génération ni conversion automatique d'une construction existante.
L'assemblage est une action volontaire avec une clé dorée. Nouveau SavedData
par dimension `sanctuary:multiblocks`, schéma 1 : origine du cube, type,
orientation et identifiant UUID. Aucun contenu d'inventaire dans ce registre.
Les 27 BlockEntities vanilla conservent leurs objets, composants, temps de
cuisson, combustible restant et expérience. Les identifiants de blocs restent
`minecraft:barrel` et `minecraft:furnace`. Le chargement d'une ancienne
sauvegarde sans ce registre ne change rien : aucun fichier nest écrit avant
le premier assemblage. Le codec refuse un schéma inconnu et le chargeur refuse
d’écraser un fichier illisible. La catégorie native de stockage NBT est
`SAVED_DATA_COMMAND_STORAGE` ; les données métier restent dans notre codec.
Une dissociation retire seulement l'appartenance : elle ne restitue jamais
une copie du stock initial. Une casse ou explosion laisse Minecraft déposer
le contenu du composant cassé et dissout la machine ; les autres composants
restent en place. Une partie déchargée suspend l'accès commun et la chauffe,
sans chargement forcé ni rattrapage hors ligne. À la reprise, validation des
27 composants. Les sauvegardes suivent l'atomicité native des chunks ; ce
lot n'ajoute pas de journal transactionnel inter-chunks en cas de crash brutal.
Les anciens inventaires ouverts sont fermés à l'assemblage/dissociation.
## Gestes et valeurs de la première version
- Clé : recette de trois lingots d'or en tête et un bâton en manche, sans usure.
- Clic droit sur 27 barils ou fours en cube plein : repérage et confirmation
par un deuxième clic dans les dix secondes. Une forme ambiguë est refusée.
- Maj + clic droit sur une machine assemblée : dissocier.
- Maj + clic droit sur un four ou baril indépendant : tourner, en conservant
ses données. Pas de rotation d'une machine chargée.
- Clic droit sans clé sur une façade : ouvrir le stock commun public.
- Fût : 729 cases physiques, 54 par page, recherche par nom et identifiant.
Les noms localisés sont résolus côté client en identifiants dobjets, puis
filtrés uniquement dans le stock accessible validé par le serveur. Le
Maj-clic de dépôt utilise tout le stock, même pendant une recherche.
Une recherche fige ses emplacements jusqu'à la prochaine recherche.
Changer de page/recherche ouvre un nouvel identifiant de menu et exige un
curseur vide ; les anciens paquets de clic ne peuvent pas viser une autre page.
- Fourneau : les 27 entrées, combustibles et sorties natifs, trois vues.
Jusqu'à 27 cuissons simultanées à vitesse vanilla, avec chaleur partagée.
Chaque cuisson paie un tick de combustible par tick de travail : le débit
augmente, pas le rendement par charbon. Les combustibles et leurs restes,
les recettes et l'XP restent gérés par le four vanilla. Pas de recette
culinaire It's Alive dans ce lot. Une panne de combustible met le travail
en attente sans effacer sa progression ; ajouter du combustible le reprend.
Le débit correspond aux 27 fours physiques, sans multiplicateur de minerai.
- Trémies : stock commun du Fût ; Fourneau, entrées en haut, combustible
latéral, résultats en bas, en respectant les règles natives des seaux.
- Apparence : composants vanilla conservés ; les fours montrent leur chauffe.
Aucune nouvelle grande texture de façade dans cette première livraison.
## Vérifications
`Multiblocks084ClientChecks` tourne dans un nouveau monde plat intégré :
confirmation volontaire, refus de chevauchement, cube traversant une frontière
de chunks, fichier SavedData écrit puis relu, schéma inconnu, sérialisation
native des objets nommés et de la cuisson, deux joueurs serveur dont un invité
automatisé, trémies natives, inventaire plein, recherche localisée, pagination,
curseur plein et paquet périmé, dissociation, casse et explosion avec conservation
du stock. Cuisson : deux recettes simultanées, rendement du charbon partagé,
sortie pleine, seau de lave/éponge mouillée, résultat natif/XP et panne puis
reprise des 27 cuissons. Captures natives en FR/EN aux échelles 2 et 3.
Validation finale réussie : `check build assemblePack assembleTestPack`
et `:sanctuary:runClientGameTest`, en excluant `:sanctuary:runGameTest`
(serveur dédié/EULA non autorisés). 131 tâches, 105 exécutées, en 3 min 16 s.
Le dernier scénario couvre aussi les six rangées, le dépôt depuis la rangée
supplémentaire et le maintien des menus voisins sans lien avec la machine.
Archives locales : `build/Sanctuary-beta.084.mrpack` et
`build/Sanctuary-Test-beta.084.mrpack`. Journaux :
`build/multiblocs084-check.log` ; reçu dintégrité :
`build/multiblocs084-artifact.json`. Pack de textures beta.070 inchangé.
Le canal publié reste inchangé.
Pour essayer rapidement : fabriquer la clé avec le motif `G G / G / S `
(G = lingot dor, S = bâton), ou obtenir `sanctuary:golden_wrench` en mode
opérateur. Construire un cube plein de 27 barils ou de 27 fours et cliquer
deux fois avec la clé. Maj-clic avec un autre bloc permet toujours den poser
un contre la façade, notamment une trémie.
Limites de cette livraison : façade composée des blocs vanilla ; comparateurs
sur le contenu du composant physique ; commandes de chauffe avancées et
recettes composées différées. La sauvegarde est vérifiée par relecture disque
et sérialisation native, pas par redémarrage complet dune session à deux
clients. Le déchargement réel dune moitié de machine pendant un transfert
reste à éprouver manuellement ; le refus daccès aux chunks absents est testé.
Aucun serveur dédié démarré, aucune EULA acceptée, aucun déploiement Prism
ou monde personnel modifié.
+204
View File
@@ -0,0 +1,204 @@
# Multiblocs — reprise du catalogue
**Cahier rouvert le 15 septembre 2026. Conception, aucune machine livrée ici.**
Le créateur demande de reprendre **tout le catalogue** avant de choisir un
premier appareil. Ce document remet les décisions historiques à portée de la
bêta ; les propositions nouvelles restent indiquées comme telles.
Le [ticket MB-01](multiblocs-ticket.md) suit ce cadrage et prépare les lots de code.
**Reprise du 16 septembre 2026 : le premier duo choisi est Fourneau + Fût.**
La séance porte sur leur conception avant intégration. Le créateur confirme
ensuite l'inclusion de la clé, le Fût à 729 cases avec recherche et pages et
le principe des fournées à combustible commun. Les gestes de la clé et les
détails de chauffe restent ouverts ; les autres appareils restent au catalogue
pour plus tard. Le créateur précise ensuite que tout It's Alive sera fusionné
dans Sanctuary, mais ultérieurement ; le Fourneau pourrait remplacer sa
cuisinière. Voir le [cadrage corrigé](multiblocs-itsalive-contrat.md).
Les huit propositions de la [recherche du 15 septembre](multiblocs-recherche.md)
ont été **rejetées par le créateur**, jugées sans intérêt ou contraires au
contrat Minecraft Vanilla. Le travail reprend sur le catalogue antérieur
ci-dessous ; cette recherche ne constitue pas une liste d'implémentation.
## Les dossiers retrouvés
La source principale est le [cahier des machines que l'on construit](../../sanctuary-conception/docs/machines-multiblocs.md),
dans le chantier de conception WG-26. Sa dernière modification est le commit
`0e95da36ae7ebc99be5768a2f51e7be65aaf6702`, du 11 septembre 2026 :
« valider lassemblage volontaire avec la clé dorée ». Le fichier consulté
ne comporte pas de modification locale.
Les dossiers complémentaires sont :
- [Redstone Language — ensemble](../../sanctuary-conception/docs/redstone-language-extensions.md) :
catalogue des composants, transport, capteurs et connexions.
- [Fiches des composants](../../sanctuary-conception/docs/redstone-language-composants.md) :
contrôleur, terminal, afficheur et disquettes.
- [Langage et machines](../../sanctuary-conception/docs/langage-et-machines.md) :
programmation et installation d'expansion Galactium.
- [Vision historique](../../sanctuary-conception/docs/vision.md) et
[backlog historique](../../sanctuary-conception/docs/backlog.md) : recoupement
des choix retenus et des propositions.
Ces liens pointent vers un autre worktree local. Le commit source est conservé
pour retrouver la référence si ce worktree est déplacé. Les dossiers historiques
restent consultés en lecture seule. Ils décrivent de la conception ; leur
présence ne prouve pas une implémentation dans la bêta actuelle.
## Le principe déjà retenu
**Des blocs identiques réunissent leur fonction dans une construction plus grande.**
Le joueur pose les composants, les assemble volontairement à la clé dorée,
utilise une machine commune, puis peut retrouver ses blocs indépendants.
- **La pose ne fusionne rien.** Un cube de fours peut rester une batterie de
fours ; neuf hoppers voisins peuvent garder neuf circuits.
- **La clé à molette dorée assemble, dissocie et oriente.** Ces fonctions sont
retenues ; recette, obtention, usure et gestes précis restent ouverts.
- **Dissocier laisse les composants sur place.** La machine ne se transforme
pas implicitement en un objet transportable. La séparation persiste après
rechargement, même si la forme reste complète.
- **Le stock actuel est conservé une seule fois.** Dissocier ne restaure pas
des ingrédients déjà consommés. La répartition entre composants est à définir.
- **L'usage local suffit.** Four, collecte ou musique restent utilisables sans
ordinateur ; la programmation pourra coordonner les appareils.
## Catalogue principal
« Retenu » désigne une décision consignée dans l'ancien cahier, encore ouverte
à une révision explicite pendant cette séance. Cela ne signifie pas « codé ».
| Appareil | Construction / fonction retenue | Ce qui reste à concevoir |
| --- | --- | --- |
| **Fourneau** — nom proposé | **27 fours ordinaires**, cube plein **3 × 3 × 3** ; chauffe commune, fournées et recettes à chaud. | Façade, orientation, entrées/sorties, chaleur, capacité, durée, rendement et première recette composée utile. |
| **Fût** — nom retenu | **27 barils**, cube plein **3 × 3 × 3** ; stockage commun par pages et/ou défilement. | Capacité réelle, pagination, dessin, faces d'accès et répartition au démontage. |
| **Grand baril de fermentation** — nom retenu | Plusieurs stacks d'**une même recette**, éventuellement à plusieurs ingrédients. | Composants, forme, volume, proportions du lot, conditions, vieillissement et temps hors ligne. |
| **Présentoirs** — nom proposé | **9 objets par face de bloc**, **81 par façade 3 × 3**, **324 distincts sur quatre façades**. Une façade seule suffit. | Bloc support et fabrication, gestes de dépôt/retrait, modèles, consultation des cartes/plans, coût du rendu. |
| **Méga-pistons** | **9 pistons en façade 3 × 3**, tête commune **3 × 3**, **course de 3 blocs** ; variante collante demandée. | Charge déplaçable, durée, collisions, traction, slime/miel, pièces déployées et interruption. |
| **Trémie** — nom retenu | **9 hoppers à plat en 3 × 3**, surface de collecte commune et **45 cases**. Assemblage facultatif. | Sortie, débit, redstone, comparateur, insertion et orientations rendues au démontage. |
| **Carillon** — nom retenu | Blocs musicaux **adjacents choisis**, pour accords et courtes séquences ; aucun carré ni nombre de neuf imposé. | Sélection, ordre, taille maximale, tempo, déclenchement, retour au début et réparation. |
| **Métablit** — nom proposé par l'auteur | La fonction d'**établi collectif avec projet persistant** est une proposition. | Confirmer cette fonction, ses composants, sa grille et ce qu'elle apporte par rapport à l'établi et au crafter. |
### Fourneau : faire une fournée et transformer une recette
Les 27 fours forment un cube **plein**, centre compris. L'ancien four creux
entouré de briques a été remplacé par ce choix. Le volume ne promet ni 27 fois
la vitesse ni un multiplicateur de minerais.
L'ancien cahier propose deux usages explicites : **cuisson par lots**, avec
les résultats des recettes admises, et **recette à chaud**, qui transforme
ensemble plusieurs ingrédients. Neuf cases d'entrée et une réserve de
combustible commune sont des propositions, pas des valeurs validées.
Le premier alliage reste à choisir avec un objet utile auquel il sert.
L'ancien cahier prévoyait de reprendre le rôle du kitchen oven en conservant
It's Alive autonome. **La direction actuelle remplace cette architecture :
tout It's Alive sera fusionné dans Sanctuary, plus tard.** Le Fourneau pourrait
alors remplacer la cuisinière, dont les usages précis restent à recenser.
Concevoir le Fourneau pour pouvoir accueillir ces recettes ultérieurement ;
le premier lot n'importe pas It's Alive et ne dépend pas d'une passerelle.
### Fût et fermentation : stockage et transformation
Le Fût conserve un stock fini, partagé entre toutes ses ouvertures ; changer
de page ne change pas les objets accessibles à l'automatisation. **729 cases**,
soit la somme des capacités de 27 barils ordinaires, ont été acceptées le
16 septembre 2026, avec recherche et pages. Ce choix actuel ne provient pas
de l'ancien cahier. La taille d'une page et l'intégration à l'inventaire
Sanctuary restent à préciser.
La fermentation engage un lot d'une recette : les ingrédients ajoutés ensuite
n'acquièrent pas rétroactivement son âge. Cette règle de lot est proposée dans
l'ancien cahier. La composition du Fût n'est pas automatiquement celle de la
cuve, et les règles du vin ne s'appliquent pas implicitement à la bière.
### Présentoirs : une collection réellement exposée
Les **81 objets d'une façade sont visibles ensemble**, sans pagination.
Un angle peut présenter deux faces avec des contenus distincts ; dessus et
dessous ne rajoutent pas implicitement des places aux 324 objets confirmés.
Cartes, photos, plans, recettes, disquettes, disques et œufs étaient demandés.
Un objet physique par emplacement est proposé ; accepter tous les autres
objets reste à éprouver. La disponibilité des supports futurs ne doit pas
être supposée dans la bêta.
### Méga-pistons : un mouvement commun
La tête parcourt réellement trois blocs. Les **27 cellules balayées** ne
définissent pas une limite de 27 blocs poussables. L'ancien cahier propose
un refus de tout le mouvement si une partie est bloquée et un démontage
uniquement rétracté et immobile. Poids, traction et déplacement de conteneurs
restent ouverts ; les états de neuf pistons indépendants ne suffisent pas
à décrire cette mécanique.
### Trémie : collecter sur une surface
La proposition historique est **une sortie sous le centre**, raccordable à
un hopper ordinaire pour poursuivre sur le côté. Cette sortie n'est pas encore
retenue. Même distinction pour la commande : alimenter le centre pour bloquer
toute la Trémie était proposé. **45 cases ne signifie pas débit multiplié par 9.**
Une destination pleine doit laisser les objets dans la réserve ; une réserve
pleine ne supprime pas les nouveaux drops.
### Carillon : construire un instrument
La forme libre adjacente remplace l'ancienne préférence pour une rangée.
L'ordre de sélection comme ordre musical reste proposé. Le cahier compare :
- **Accord :** une impulsion joue les notes ensemble.
- **Séquence :** une impulsion avance d'une note, avec tempo donné par la redstone.
- **Variante à discuter :** une impulsion lance toute une phrase au tempo interne.
Chaque note garde son instrument, notamment son support propre. Les gestes
d'accordage doivent rester accessibles. Le nombre de notes, les silences et
la manière de réordonner la mélodie restent à concevoir.
## Pistes et installations voisines
| Piste | État retrouvé |
| --- | --- |
| **Veilleur** | Proposition : 9 observateurs en façade pour surveiller les positions devant eux et identifier laquelle a changé. |
| **Batterie de distribution** | Proposition : 9 distributeurs en façade, sélection d'une bouche ou séquence avec leur stock réel. |
| **Bassin de traitement, serre climatique, écluse, alambic** | En réserve ; pas automatiquement des assemblages de blocs identiques. |
| **Presse à moule** | Écartée par l'auteur, fonction jugée déjà couverte par le crafter. |
| **Afficheur programmable** | Cahier voisin : panneaux coplanaires, rectangle plein, texte continu et huit lignes par bloc de hauteur. Ses règles de regroupement ont leur propre contrat, à confronter à celles de la clé. |
| **Installation Galactium** | Proposition voisine : construction autour d'un contrôleur, ressources et charge collective pour une expansion. Dépend du contrat d'expansion ; ne fait pas partie d'un premier appareil autonome. |
Le **terminal en titane reliant jusqu'à 128 coffres** est un réseau de stockage
distinct du Fût. Le coffre 3 × 3 × 3 cité dans la discussion historique n'y a
pas été identifié comme implémenté. Ces références ne deviennent pas de nouvelles
machines validées par leur simple mention.
## Proposition pour reprendre la conception ensemble
Pour chaque appareil, partir de **ce que la construction change dans le jeu** :
une chauffe partagée, un dépôt commun, une collection visible, une grande
course, une surface de collecte ou une phrase musicale. Puis compléter une
fiche courte :
1. **Construire :** blocs, forme, taille fixe ou variantes, orientation.
2. **Reconnaître :** aspect assemblé, pièces actives, état visible, nom FR/EN.
3. **Utiliser :** gestes, inventaire éventuel, recette ou commande, effet obtenu.
4. **Raccorder :** faces d'entrée/sortie, hoppers et redstone.
5. **Démonter :** casse, dissociation, récupération du contenu et reprise.
6. **Progresser :** obtention, matériaux, intérêt par rapport aux blocs séparés.
Les trois premiers arbitrages transversaux proposés sont **l'aspect des blocs
une fois réunis**, **les gestes de la clé** et **les tailles disponibles**.
Le 3 × 3 et le 3 × 3 × 3 sont retenus pour certains appareils ; les étendre
à toutes les machines effacerait le choix particulier du Carillon et des
façades de collection. Aucun nouvel arbitrage n'est acquis à ce stade.
## État et vérification de cette reprise
Le catalogue et ses statuts ont été recoupés avec le cahier, la vision et le
backlog de conception. La recherche ciblée dans les sources et les traductions
de la bêta n'a pas retrouvé ces appareils sous leurs noms ; elle ne constitue
pas un audit exhaustif de tout le code historique.
La cible déclarée reste celle de `gradle.properties`, **26.3-pre-2** au moment
de la lecture. Les anciennes vérifications d'API ne dispensent pas le futur
ticket de vérifier ses usages sur cette version exacte.
Cette reprise modifie uniquement la documentation. Aucun build ni essai en
jeu n'est nécessaire pour le catalogue ; les validations de gameplay figurent
dans le ticket et seront exécutées lors des lots concernés.
+51
View File
@@ -0,0 +1,51 @@
# Fourneau et It's Alive — fusion future dans Sanctuary
16 septembre 2026. Cadrage corrigé après clarification du créateur.
Aucun portage, adaptateur ou changement de sauvegarde livré ici.
## Direction retenue
Le créateur souhaite intégrer **tout It's Alive dans Sanctuary**, mais
**pas maintenant**. La proposition précédente de maintenir deux mods
indépendants reliés par un module multiblocs partagé est abandonnée pour ce
chantier. Aucune passerelle entre mods n'est un prérequis au premier lot.
Le lot actuel reste **clé dorée, Fourneau et Fût**. Le Fût à 729 cases avec
recherche et pages et le principe du Fourneau à fournées et combustible commun
sont retenus. Les gestes de la clé, la chauffe et les détails de fonctionnement
restent à concevoir dans le ticket multiblocs.
## Place future du Fourneau
Le Fourneau **pourrait remplacer la cuisinière d'It's Alive** lors de la
fusion. C'est la piste exprimée par le créateur (« je pense »), pas encore
un remplacement détaillé et définitivement arbitré.
Ne pas réduire cette intention à la seule classe historique
`KitchenOvenBlockEntity` : il faudra identifier précisément les usages de
la cuisinière à reprendre au moment de la fusion. Les autres appareils,
recettes et systèmes d'It's Alive seront examinés dans ce futur chantier.
Pour éviter une réécriture inutile, concevoir la cuisson du Fourneau de façon
à pouvoir accueillir ultérieurement des recettes culinaires composées.
Cette possibilité d'évolution n'impose aujourd'hui ni import de recettes,
ni moteur culinaire complet, ni nouveau module autonome.
## Références techniques conservées pour plus tard
Le projet bêta charge Sanctuary, Demeure, JEI et le module de test ; It's
Alive n'est pas déclaré dans `settings.gradle`.
La référence consultée en lecture seule est `../26.2/itsalive/` :
- son manifeste cible Minecraft `~26.2`, pas `26.3-pre-2` ;
- `KitchenOvenBlockEntity` possède neuf cases d'ingrédients, une case de
combustible et une sortie ;
- `AbstractTimedCulinaryBlockEntity` recherche les recettes, calcule les lots,
suit leur durée, consomme les ingrédients et produit les résultats.
Ces constats serviront à l'inventaire du futur portage, sans présumer que
ce four constitue à lui seul toute la cuisinière visée par le créateur.
La reprise des identifiants, des contenus déjà stockés et des travaux en
cours devra recevoir son contrat de migration avant toute conversion.
Les sources historiques et les sauvegardes restent inchangées.
+290
View File
@@ -0,0 +1,290 @@
# Multiblocs — recherche de compléments au catalogue
**Recherche du 15 septembre 2026, pour MB-01. Propositions rejetées par le créateur.**
**Décision après présentation :** les huit candidats sont écartés, jugés sans
intérêt ou contraires au contrat Minecraft Vanilla. Le contenu ci-dessous est
conservé comme historique de recherche, y compris son ordre proposé désormais
sans valeur de sélection. Reprendre le [catalogue antérieur](multiblocs-conception.md)
pour déterminer la liste d'implémentation ; ne pas réintroduire ces propositions
comme candidates actives sans nouvelle demande du créateur.
La demande est de compléter le [catalogue retrouvé](multiblocs-conception.md)
avant de déterminer ensemble la liste d'implémentation. Aucune nouvelle entrée,
forme, recette ou priorité ci-dessous n'est une décision du créateur.
## Conclusion proposée avant rejet — historique
Huit candidats méritent une discussion : **Atelier de taille, Alambic,
Compostière, Citerne, Broyeur, Tisserie, Discothèque et Antenne sculk**.
L'Alambic précise une piste déjà en réserve. La Citerne donne un usage de
stockage au thème des bassins, distinct d'un bassin de transformation.
Je commencerais la sélection des ajouts par **Atelier de taille, Alambic et
Compostière**, puis **Discothèque** pour un usage collectif musical. La
**Citerne** devient intéressante si l'on souhaite des installations de liquides.
Le **Broyeur** mérite un arbitrage sur les ressources avant d'être retenu.
**Tisserie** et **Antenne sculk** ont un potentiel plus spécialisé à éprouver.
Leur intérêt vient respectivement des commandes de chantier, des préparatifs
d'expédition, de la valorisation des récoltes, d'une programmation musicale,
d'une réserve liquide, de transformations minérales, de motifs textiles et
de la lecture des vibrations. Les rangs ci-dessus sont mon appréciation de
conception, pas des mesures de popularité ou de temps de développement.
## Ce que les références apportent
Recherche dans les documents des projets et de Mojang. Les exemples attestent
des principes de jeu ; **leur compatibilité avec Fabric 26.3-pre-2 n'a pas été
évaluée et aucun de ces mods/plugins n'est proposé comme dépendance**.
| Référence consultée | Fait utile à la conception | Application proposée à Sanctuary |
| --- | --- | --- |
| [Create — Mechanical Crafter](https://github.com/Creators-of-Create/Create/wiki/Mechanical-Crafter), ancien wiki du projet, page de 2021 | Liaisons et direction se règlent à la clé ; des groupes adjacents peuvent rester distincts. | Faire voir l'appartenance et les raccords, garder les ingrédients lisibles dans la construction. |
| [Create — Crushing Wheels](https://github.com/Creators-of-Create/Create/wiki/Crushing-Wheels), page de 2024 | Deux roues transforment des objets introduits entre elles, avec des recettes de broyage. | Donner au Broyeur un geste et une transformation identifiables ; choisir nos propres recettes et rendements. |
| [Create — Mechanical Pump](https://github.com/Creators-of-Create/Create/wiki/Mechanical-Pump), page de 2021 | Pompage, sens et tuyaux assurent le transfert physique des fluides. | Distinguer réserve, transfert et transformation ; une Citerne seule n'est pas une pompe. |
| [Botania — Botanical Brewery, dans le Lexicon](https://botaniamod.net/lexicon.html) | La préparation rassemble récipient, ingrédients et mana avant de produire un breuvage. | Montrer le lot engagé et son état dans un atelier ; mana et breuvages Botania ne sont pas repris. |
| [CraftBook — Bridge](https://craftbook.enginehub.org/en/3.x/mechanics/bridge/) | Une construction de pont peut être commandée par panneau ou redstone. | Concevoir des usages architecturaux des méga-pistons : passage, porte et quai mobile. |
| [Immersive Engineering — changelog 1.21.1](https://github.com/BluSunrize/ImmersiveEngineering/blob/1.21.1/changelog.md) | Le projet documente des corrections de pertes de contenu à la casse de machines et de problèmes de fluides au chargement des chunks. | Mettre conservation du contenu et reprise dans le premier scénario de réception, avant de multiplier les appareils. |
Ces anciens documents de Create sont utilisés comme références historiques de
conception. Le [wiki GitHub annonce son déplacement](https://github.com/Creators-of-Create/Create/wiki)
vers un nouveau site ; ses pages récentes n'ont pas été récupérées lors de cette
recherche. Les descriptions ci-dessus ne prétendent donc pas résumer la dernière
version distribuée de Create.
## Huit fiches candidates
Les gabarits et noms anglais sont proposés pour rendre les idées examinables.
Ils restent modifiables. Tous les regroupements suivent la clé volontaire et
la dissociation durable du catalogue. Aucun appareil ne fonctionne gratuitement
du seul fait qu'il est plus grand.
### 1. Atelier de taille / Stoneworks
**Base vérifiée :** le tailleur de pierre transforme un matériau selon une
recette choisie. [Présentation Mojang du tailleur de pierre](https://www.minecraft.net/de-de/article/block-week--stonecutter).
**Proposition :** neuf tailleurs de pierre en plateau **3 × 3** donnent un
atelier de préparation de chantier. Le joueur prépare une commande avec
plusieurs sorties et quantités : par exemple escaliers, dalles et murets dans
la même pierre. L'atelier répartit les matériaux apportés entre les recettes,
montre ce qui manque et s'arrête aux quantités demandées.
Le bénéfice est une commande persistante avec approvisionnement commun,
utilisable à la main et par hoppers. Un raccord ultérieur aux besoins d'un plan
peut préparer la liste, mais le premier atelier fonctionne par saisie manuelle.
Il produit des objets ; les règles de pose et la compétence Construction
continuent de s'appliquer lors du chantier.
**À décider :** nombre de lignes de commande, coût du travail, cadence et
sorties. Réutiliser les recettes admises ; ne pas ouvrir silencieusement le
catalogue encore inconnu du joueur. **Essai :** deux commandes concurrentes,
stock insuffisant puis complété, sortie pleine, reprise sans surproduction.
### 2. Alambic / Brewing Array
**Base vérifiée :** l'alambic vanilla combine fioles et ingrédients avec du
combustible. [Présentation Mojang de l'alambic](https://www.minecraft.net/en-us/article/taking-inventory--brewing-stand).
**Proposition :** neuf alambics en plateau **3 × 3** deviennent un atelier
commun de potions. Préparer un lot homogène, afficher ses étapes, puis les
enchaîner avec les ingrédients physiquement déposés. Exemple : préparer les
potions de résistance au feu d'une expédition et les récupérer terminées.
Une capacité de **27 fioles** est une proposition issue des trois fioles par
composant, pas une capacité déjà approuvée.
Le regroupement doit apporter la préparation d'un lot et le suivi des étapes.
Les recettes de potions et leurs effets restent ceux admis par le jeu.
La fermentation alimentaire garde son propre appareil et sa temporalité.
La forme et le rôle de cet Alambic précisent la piste ancienne sans l'imposer.
**À décider :** consommation par groupe de fioles, partage de chauffe,
cadence et ordre des ingrédients. Une seule dose ne traite pas arbitrairement
27 fioles. **Essai :** un lot partiel, ingrédient manquant au milieu, retrait
pendant le cycle et redémarrage ; aucune étape ni consommation doublée.
### 3. Compostière / Composting Bed
**Base vérifiée :** le composteur accepte des matières végétales et produit
de la poudre d'os ; leur efficacité varie. [Présentation Mojang du composteur](https://www.minecraft.net/en-us/article/block-month--composter).
**Proposition :** neuf composteurs à plat **3 × 3**, une réserve commune
alimentée par les récoltes et une maturation visible. La construction reçoit
un lot mélangé d'objets compostables ; la poudre d'os terminée est récupérée
dans une sortie identifiée. Le travail peut accompagner un jardin partagé.
Le gain est la gestion d'un lot et la collecte de ses résultats, avec un
remplissage lisible sur une grande surface. Le premier essai conserve les
matières admises et compare le résultat à neuf composteurs indépendants.
Un compost spécial, des engrais ou des sols enrichis seraient une extension
à concevoir avec It's Alive ! ; ils ne sont pas requis pour cette première fiche.
**À décider :** progression commune ou neuf cellules de maturation derrière
le stock commun, rendement, cadence et éventuel travail manuel.
**Essai :** graines acceptées, objet non compostable refusé, mélange de stocks,
sortie pleine et maturation conservée après déchargement.
### 4. Citerne / Cistern
**Base vérifiée :** les chaudrons stockent notamment eau et lave ; certaines
fonctions de potions et de teinture de leur présentation appartiennent à
Bedrock. [Présentation Mojang des chaudrons](https://www.minecraft.net/en-us/article/cauldron).
**Proposition :** neuf chaudrons en **3 × 3** forment une réserve commune
avec niveau visible et point de puisage. Premier usage proposé : l'eau,
apportée et retirée avec de vrais récipients. La lave serait un autre contenu
admis, à décider avec son comportement de rupture. Un bassin unique conserve
un seul contenu compatible ; aucune conversion d'un liquide en un autre.
La Citerne peut devenir le réservoir d'une cuisine ou d'un atelier de potions.
Son intérêt augmente avec les futures connexions physiques ; stockage et
pompage restent deux fonctions distinctes. La capacité ne doit pas seulement
dupliquer celle d'un coffre de seaux sans avantage d'usage perceptible.
**À décider :** capacité, unités, pluie éventuelle, raccords, récipients et
restitution au démontage. Les hoppers déplacent les objets récipients selon
le contrat ; ils ne deviennent pas implicitement des tuyaux de liquide.
**Essai :** remplir, prélever une quantité, reprendre après sauvegarde et
dissocier sans créer de seaux ni déverser involontairement tout le stock.
### 5. Broyeur / Crusher
**Référence :** Create documente le broyage de cobblestone en gravier et de
gravier en sable dans sa liste de transformations. [Crushing Wheels](https://github.com/Creators-of-Create/Create/wiki/Crushing-Wheels).
**Proposition :** deux rangées de trois meules, séparées par un passage,
forment deux rouleaux qui traitent des matériaux déposés. Une autre forme
compacte reste possible. Il s'agit d'une **nouvelle fonction** donnée aux
meules assemblées, à valider explicitement : la meule vanilla n'est pas un
broyeur de roche. Le trajet des objets explique visuellement la transformation.
Des granulats pour la construction seraient un premier usage. La mouture des
aliments reste à coordonner avec le moulin déjà prévu dans It's Alive !.
Recettes de recyclage, pigments et traitement des minerais sont des extensions
distinctes ; aucun doublement de minerai n'est adopté par la référence à Create.
**Arbitrage central :** la chaîne pierre renouvelable → gravier → sable
modifierait l'intérêt des gisements et de l'exploration dans Sanctuary.
Décider si ce résultat est souhaité, puis choisir rendement et coût réel.
**Essai :** bilan de matière d'une recette, cadence, arrêt et boucles de
recyclage ; le retour ne doit pas fabriquer plus de matière qu'il n'en engage.
### 6. Tisserie / Textile Workshop
**Base vérifiée :** le métier à tisser applique des motifs et couleurs
successifs aux bannières. [Présentation Mojang du métier à tisser](https://www.minecraft.net/en-us/article/block-week--loom).
**Proposition :** six métiers à tisser en façade **3 × 2** deviennent un
atelier qui conserve une composition de motifs, ses couleurs et une commande
de bannières. Plusieurs joueurs apportent supports, colorants et motifs ; la
construction présente un grand aperçu et prépare la série demandée.
L'intérêt à éprouver est l'édition persistante d'une composition et son suivi
matériel, par exemple pour une faction. Une simple copie de bannière ne suffit
pas à justifier cette machine. Textile culinaire, tentures et nouveaux tissus
ne deviennent pas des dépendances obligatoires de cette proposition.
**À décider :** avantage sur les outils de bannière existants, format du modèle,
droits sur les motifs, consommation et lien avec les collections.
**Essai :** réalisation d'un motif composé, réapprovisionnement et reprise ;
une bannière rare ou un motif manquant ne doit pas être copié gratuitement.
### 7. Discothèque / Record Console
**Base vérifiée :** Minecraft Java permet déjà les interactions des hoppers
et droppers avec les jukebox, ainsi qu'un signal pendant la lecture.
[Notes officielles Java 1.19.4](https://www.minecraft.net/en-us/article/minecraft-java-edition-1-19-4).
**Proposition :** plusieurs jukebox adjacents choisis, par exemple un meuble
**3 × 2**, forment une collection écoutable avec ordre de lecture, prochain
morceau, arrêt et répétition. Chaque disque reste un objet réel ; une seule
lecture commune est émise. Le meuble pourrait montrer les disques disponibles.
La valeur est le choix et la modification d'une programmation musicale
sur place. Le Carillon compose des notes ; la Discothèque joue des disques.
Une simple boucle automatique serait trop proche des montages déjà possibles.
**À décider :** sélection du groupe, capacité, interface, commandes redstone,
reprise du morceau et portée sonore. **Essai :** modifier la liste pendant
l'écoute, rejoindre à deux joueurs, retirer le disque actif et casser un
composant ; pas de musique fantôme ni de disque perdu.
### 8. Antenne sculk / Vibration Array
**Base vérifiée :** un capteur sculk calibré filtre les vibrations suivant
un signal ; Mojang documente également la réémission avec l'améthyste.
[Snapshot Java 23w12a](https://www.minecraft.net/de-de/article/minecraft-snapshot-23w12a).
**Proposition :** neuf capteurs calibrés en surface **3 × 3**, avec lecture
commune des catégories reçues et petite mémoire des derniers événements.
Plusieurs catégories peuvent être surveillées et affichées ensemble. Exemple :
une porte réagit à une courte suite de gestes ; l'instrument permet de voir
lequel a été reçu ou manque. La durée de mémoire reste bornée.
Le Veilleur observe des changements de blocs devant lui. L'Antenne apporte
une lecture d'événements vibratoires ; elle ne révèle pas automatiquement le
nom d'un joueur, ses coordonnées ou des lieux hors de portée. Le simple gain
de rayon ne suffit pas à justifier neuf composants.
**À décider :** filtres, lecture visible, sorties et distinction avec un
contrôleur branché sur des capteurs ordinaires. **Essai :** catégories distinctes,
obstacles de laine, événements simultanés, boucles provoquées par la propre
machine et expiration de la mémoire.
## Compléter les machines existantes avant de les multiplier
| Idée examinée | Recommandation pour notre catalogue |
| --- | --- |
| Grande fonderie, four à alliages, grand fumoir | Examiner d'abord les modes et recettes du **Fourneau** ; un autre bâtiment doit offrir un procédé distinct. |
| Silo à matériau unique | Examiner un réglage du **Fût** avant d'ajouter un deuxième stockage volumineux. |
| Table de fabrication géante | Préciser le **Métablit** ; le [crafter vanilla automatise déjà la fabrication](https://www.minecraft.net/en-us/article/crafting-crafter). Une grille plus grande demande des recettes et un usage propres. |
| Pont rétractable, grande porte, élévateur court | En faire des montages de réception des **méga-pistons**, avec leur course de trois blocs. Un ascenseur à longue course demanderait un autre contrat. |
| Mur de cartes, bibliothèque de plans | Éprouver les **présentoirs** et les plans existants avant de créer une table cartographique supplémentaire. |
| Grand afficheur lumineux | Confronter à l'**afficheur programmable** déjà conçu ; ne pas créer deux systèmes d'écran par simple changement de nom. |
| Presse à moule | Conserver le choix historique d'abandon. La présence de presses dans d'autres mods ne réouvre pas cette décision à elle seule. |
| Serre qui accélère tout, carrière automatique, générateur de ressources | Réserver une conception propre avec coûts et conséquences sur exploration, métiers, progression et terrain. |
## Ancienne base de liste d'implémentation — non retenue
**Ordre conseillé, encore à choisir ensemble.** Chaque appareil reste un lot
jouable autonome ; les groupes ci-dessous rapprochent les usages et dépendances.
La clé arrive avec le premier appareil. Aucune branche de code n'est créée ici.
| Groupe proposé | Entrées | Pourquoi cet ordre / condition |
| --- | --- | --- |
| **1. Première construction** | **Clé dorée + Fût** | Éprouver assemblage volontaire, contenu commun, accès à deux et dissociation avec une fonction immédiatement utilisable. |
| **2. Atelier quotidien** | **Trémie, Fourneau, Atelier de taille, Compostière** | Faire circuler, transformer et préparer les matériaux. Le Fourneau exige sa première recette composée utile et un choix de chauffe. |
| **3. Lieux collectifs** | **Présentoirs, Carillon, Discothèque** | Donner des usages visibles aux collections et à la musique ; ces lots peuvent avancer indépendamment des recettes industrielles. |
| **4. Préparation et liquides** | **Alambic, fermentation, Citerne** | Fixer les lots, consommations et règles de temps. La fermentation dépend du contrat culinaire ; l'Alambic peut fonctionner avec ses fioles sans attendre la Citerne. |
| **5. Mécanismes** | **Méga-pistons, Veilleur, batterie de distribution** ; **Antenne sculk** à départager | Éprouver grandes constructions mobiles, observation et commandes. L'Antenne doit démontrer son apport face aux capteurs et au futur contrôleur. |
| **6. Recettes et ateliers spécialisés** | **Broyeur, Métablit, Tisserie** | Choisir respectivement l'effet sur les ressources, le rôle du projet collectif et l'avantage d'une composition textile persistante. |
Les familles historiques retenues gardent leur statut ; les nouvelles entrées
de cette table sont des candidates. Si la première livraison doit montrer
prioritairement une transformation plutôt qu'un stockage, **Fourneau + clé**
reste une alternative cohérente au Fût. Ce choix appartient à la prochaine
discussion, pas à la recherche seule.
## Vérification de la cible et limites de l'étude
Lecture complémentaire du JAR de sources Minecraft **26.3-pre-2** déjà présent
dans le cache Gradle local, sans copie des dépendances dans Git :
- `StonecutterMenu` : recettes à entrée unique, sélection et résultat natif.
- `BrewingStandBlockEntity` : trois positions de fioles ; combustible lu via
`DataComponents.BREWING_FUEL`, durée influencée par son multiplicateur.
- `ComposterBlock` : couches apportées par le composant `Compostable` et son
contexte, plutôt qu'une table d'efficacité à recopier depuis un vieux guide.
- `CalibratedSculkSensorBlockEntity` : filtre de fréquence et rayon d'écoute
local de 16 blocs ; aucune portée de multibloc décidée par cette lecture.
- `JukeboxBlockEntity` : lecteur de morceau, objet conservé et données de reprise.
Ces lectures vérifient des points précis et ne constituent pas un prototype
de multibloc ni une mesure de performance. Le modèle de chaudron de Bedrock
n'est pas une preuve de comportement Java ; le futur contrat des liquides doit
être vérifié sur la cible avant implémentation.
La recherche a porté sur les fonctions, les gestes, les recouvrements et les
dépendances. Le coût relatif d'implémentation est une appréciation technique ;
aucune estimation de durée ni compatibilité de mod externe n'est promise.
Liens locaux et diff documentaire vérifiés. Aucun code ou binaire modifié.
+153
View File
@@ -0,0 +1,153 @@
# MB-01 — Cadrer le catalogue des machines multiblocs
**Ticket local ouvert le 15 septembre 2026 — conception en cours.**
Le créateur demande de reprendre tout le catalogue avant de sélectionner le
premier lot d'implémentation. Le [cahier de reprise](multiblocs-conception.md)
conserve les sources, les anciennes décisions et les propositions ouvertes.
**Décision sur le complément :** les huit candidats de la
[recherche sourcée](multiblocs-recherche.md) sont rejetés par le créateur,
jugés sans intérêt ou contraires au contrat Minecraft Vanilla. Reprendre
la liste antérieure ; les recommandations de cette recherche ne sont pas retenues.
Branche documentaire réservée : `codex/multiblocs-ticket`.
Elle est créée ; le checkout de travail existant reste sur sa branche en cours.
Aucun numéro `beta.xxx` réservé et aucune issue distante publiée.
## Ce que le joueur doit pouvoir faire à terme
Construire une machine à partir de blocs, choisir de les réunir avec la clé
à molette dorée, bénéficier d'une fonction commune visible, puis retrouver
les composants indépendants sans perdre ni dupliquer le stock actuel.
Les blocs simplement voisins gardent leur fonctionnement individuel.
## Résultat vérifiable de ce ticket de conception
Obtenir un catalogue dont chaque entrée indique sa fonction, son statut,
ses choix ouverts et un scénario jouable de réception. Compléter ensuite les
fiches retenues et ouvrir des tickets d'implémentation autonomes avec leur
branche. **MB-01 ne déclare pas toutes les machines prêtes à coder en un seul lot.**
**Sélection du 16 septembre 2026 : Fourneau et Fût.** Le créateur demande
de réfléchir à leur intégration. Ce duo constitue le premier périmètre à
concevoir ; aucun ordre de livraison entre les deux ni valeur de fonctionnement
supplémentaire n'est encore validé. Branche de cette reprise documentaire :
`codex/multiblocs-fourneau-fut-cadrage`.
Confirmation du créateur : **la clé dorée fait partie du premier lot**.
Les cubes pleins de 27 fours et de 27 barils, la cuisson par fournées avec
combustible commun et le Fût à **729 cases avec recherche et pages** sont
acceptés. Les neuf entrées du Fourneau, ses durées, son débit, les gestes et
la recette de la clé restent à préciser.
**Clarification : tout It's Alive sera intégré à Sanctuary ultérieurement,
pas dans ce lot.** Le Fourneau pourrait alors remplacer la cuisinière ; le
détail de ce remplacement reste à définir. Le [cadrage corrigé](multiblocs-itsalive-contrat.md)
remplace la proposition de passerelle entre mods autonomes. Le premier lot
multiblocs ne dépend pas d'un portage immédiat d'It's Alive.
## Périmètre à passer en revue
Fourneau, Fût, grand baril de fermentation, présentoirs, méga-pistons, Trémie,
Carillon et Métablit. Réexaminer séparément Veilleur et batterie de distribution.
Conserver les installations en réserve et les idées écartées avec leur statut.
Rapprocher les contrats de l'afficheur et de Galactium lorsque nécessaire,
sans imposer leur livraison aux machines utilisables localement.
## Décisions à produire avant les tickets de code
- [ ] Pour chaque entrée, confirmer maintien, révision, report ou abandon.
- [x] Compléments de la recherche arbitrés : les huit propositions sont
rejetées. Aucun de ces ajouts n'entre dans la liste d'implémentation.
- [x] Premier périmètre sélectionné : Fourneau et Fût, le 16 septembre 2026.
- [ ] Compléter les six rubriques de la fiche du cahier : construction,
apparence, usage, raccords, démontage et progression. Distinguer les nombres
retenus des valeurs d'essai, et donner les noms et libellés FR/EN nécessaires.
- [ ] Fixer les gestes de la clé sans conflit entre tourner, sélectionner,
assembler et dissocier ; décrire l'aperçu, l'annulation et les refus visibles.
- [ ] Fixer obtention, recette et éventuelle usure de la clé. Définir les premiers
blocs orientables, la conservation de leur état et les opérations admissibles.
- [ ] Décider apparence après assemblage, orientations et éventuelles tailles
supplémentaires, appareil par appareil.
- [ ] Choisir un premier résultat jouable et rédiger son ticket d'implémentation
avec valeurs d'essai, dépendances réelles et branche `codex/<sujet>`.
## Découpage d'implémentation proposé
Fourneau et Fût sont les deux lots retenus pour la conception de la première
intégration. Les autres lignes restent des **lots candidats**, sans ordre de
livraison validé ni sous-ticket distant publié. Le premier appareil livré emporte
la clé et le contrat commun nécessaires à son usage réel ; éviter de livrer
un mécanisme d'assemblage sans machine utilisable.
| Lot candidat / branche prévue | Résultat jouable et vérifiable |
| --- | --- |
| **Fourneau**`codex/multibloc-fourneau` | Assembler 27 fours, cuire une fournée, retirer le résultat, arrêter/reprendre et dissocier. Chauffe et capacités à préciser. Prévoir l'accueil futur de recettes composées ; la fusion culinaire d'It's Alive est différée. |
| **Fût**`codex/multibloc-fut` | Assembler 27 barils, déposer et retrouver un stock commun sur plusieurs pages, l'utiliser à deux joueurs et avec un hopper, puis dissocier sans perte. |
| **Fermentation**`codex/multibloc-fermentation` | Engager plusieurs stacks d'une recette aux bonnes proportions, suivre le lot et récupérer une seule production. Dépend d'un contrat culinaire et temporel explicite. |
| **Présentoirs**`codex/multibloc-presentoirs` | Remplir une façade de 81 objets distincts visibles et quatre façades totalisant 324, consulter les supports admis et récupérer les objets d'un angle cassé une seule fois. |
| **Méga-pistons**`codex/multibloc-mega-pistons` | Commander une tête 3 × 3 sur une course animée de trois blocs, vérifier un obstacle intermédiaire, la variante collante et une interruption. |
| **Trémie**`codex/multibloc-tremie` | Choisir entre neuf circuits individuels et une collecte commune de 45 cases, constater la sortie et son débit, le verrouillage retenu et le cas d'une destination pleine. |
| **Carillon**`codex/multibloc-carillon` | Choisir un groupe adjacent, jouer un accord puis une courte séquence selon le mode retenu, réaccorder une note et garder un groupe voisin indépendant. |
| **Métablit**`codex/multibloc-metablit` | Si la fonction est retenue : préparer un projet persistant à deux joueurs, quitter/revenir, apporter les pièces manquantes et produire un résultat unique. |
## Contrat commun requis par la première implémentation
Le code reste côté serveur pour l'appartenance des composants, les stocks,
les cycles et les opérations. Un bloc ne peut appartenir à deux machines.
La reconnaissance des formes est bornée ; elle ne charge pas des chunks
distants. Les protections effectivement présentes sont respectées.
Avant de créer des données persistantes, écrire un **contrat de sauvegarde
et de migration explicite** : identité de machine, composants et orientations,
contenu actuel, travail engagé, version, suspension et reprise. Charger une
ancienne construction ne doit pas l'assembler. Définir le devenir des matières
en cas de casse, d'explosion, de dissociation et d'interruption, ainsi que les
opérations refusées. Un démontage impossible faute de place attend une
récupération explicite ; il ne supprime pas le surplus.
Décharger une partie suspend le travail concerné sans produire deux fois ni
rattraper silencieusement une durée hors ligne. Une éventuelle fermentation
hors ligne reçoit son propre contrat. Le démontage conserve l'état courant,
y compris les combustibles déjà dépensés et les résultats déjà obtenus.
La représentation technique des composants reste à choisir. Aucun nouveau
registre ni renommage d'identifiant `sanctuary:*` n'est décidé par ce ticket.
Les plans et les recettes accessibles conservent leurs responsabilités
actuelles. La fusion future d'It's Alive ne rend pas ses fonctions disponibles
dans ce lot du seul fait de la conception historique.
## Vérifications exigées des futurs lots
- [ ] Sans clé, poser une forme complète conserve ses blocs indépendants.
Assembler, dissocier puis recharger conserve le choix explicite du joueur.
- [ ] Une forme incomplète, un chevauchement, une mauvaise orientation ou un
accès refusé donne une explication sans modification partielle.
- [ ] Deux interactions simultanées, un transfert automatique et deux façades
ouvertes travaillent sur le même stock ; aucune duplication de contenu.
- [ ] Inventaires pleins, stocks préexistants, rupture, déchargement partiel,
redémarrage et réassemblage conservent matières, état et résultats selon
le contrat retenu. Tester aussi les composants répartis entre deux chunks.
- [ ] Le scénario propre à la machine fonctionne en survie par la voie
d'obtention retenue, avec interface FR/EN, puis à deux joueurs.
- [ ] Les tests utiles et `./gradlew check build` passent. Si la distribution
change, `./gradlew assemblePack` passe ; livraison et incrément mod/pack
suivent [le versionnement](versioning.md). Aucun déploiement personnel
n'est implicite dans ce ticket.
## État de la préparation
Catalogue retrouvé, décisions historiques recoupées, scénarios proposés et
références locales vérifiées. Fourneau et Fût sélectionnés ; **leurs modalités
de fonctionnement et les autres arbitrages ci-dessus restent ouverts.**
La demande courante porte sur la conception : aucun code, binaire, monde ou
format de sauvegarde modifié, aucun test de gameplay présenté comme exécuté.
## Livraison du premier lot — beta.084
La demande a évolué du cadrage à limplémentation le 16 septembre 2026.
Clé, Fût et Fourneau sont implémentés sur `codex/multiblocs-beta084` ; le
[contrat de livraison](multiblocs-beta084.md) précise les valeurs retenues,
la sauvegarde, les tests et leurs limites. Il remplace l’état documentaire
« aucun code modifié » ci-dessus pour ce premier lot uniquement. Les autres
multiblocs et la fusion It's Alive restent différés.
+217
View File
@@ -13,6 +13,223 @@ JAR Sanctuary sont des pièces jointes de releases Gitea. Aucun JAR de mod ou
dépendance téléchargée n'entre dans l'historique Git. Le canal Beta est distinct
de l'ancien pack 26.2.
## beta.092 — Clic molette des super pistons
Les packs locaux normal/Test beta.092 corrigent la sélection des composants
de super piston. Dépendances et textures beta.090 conservées.
[Contrat et vérifications](multiblock-pick-beta092.md). Aucun canal publié
ni instance personnelle modifié.
## beta.091 — Interactions des passagers
Les packs locaux normal/Test beta.091 ajoutent les clics sur les animaux embarqués
et permettent de les viser à larrière du bateau. Minecraft 26.3, Fabric et JEI
restent ceux de beta.090 ; les textures beta.090 sont conservées.
[Contrat et vérifications](boat-passengers-beta091.md). Aucune publication du canal
ou installation personnelle nest effectuée.
## beta.090 — Minecraft 26.3 finale
La cible locale passe à Minecraft 26.3, Fabric API 0.160.5+26.3, avec Loader
0.19.5 et Java 25. Demeure et JEI sont reconstruits pour la même version ;
le pack graphique devient beta.090. [Contrat et vérifications](minecraft-26-3-beta090.md).
Les archives normal/Test beta.090 sont des livraisons locales : le canal
publié et l'instance Prism ne sont pas modifiés par cette migration du projet.
## beta.077 — tourbillon et navigation
[Nuages](cloud-vortex-beta077.md) · [Menus](menu-navigation-beta076.md).
Client natif, `check build`, sources et archives normal/Test vérifiés.
Textures beta.070 et 155 anciennes archives conservées. Le lot de menus
beta.076 est inclus ici ; aucun pack intermédiaire beta.076 na été publié.
Aucun déploiement dans une instance personnelle ni sur le canal distant.
- [Sanctuary-beta.077.mrpack](../build/Sanctuary-beta.077.mrpack), 9608669 octets.
SHA-256 : `1c3839f086e9545c920e4ebfc13f99a25d0684fa6f2dc8e1b2a842a8e7044045`.
- [Sanctuary-Test-beta.077.mrpack](../build/Sanctuary-Test-beta.077.mrpack), 9627602 octets.
SHA-256 : `1b0c302b9d4d5f59d5e954adbecde4acbacd9409c8cd6a743ccbe116826d7c0a`.
## beta.075 — bibliothèque locale
[Correctif et vérifications](plans-library-beta075.md). Retrait du bouton
Minecraft Schematics. Build, sources, JAR et exports normal/Test vérifiés ;
onze archives précédentes conservées. Aucun déploiement personnel ou distant.
- [Sanctuary-beta.075.mrpack](../build/Sanctuary-beta.075.mrpack), 9594917 octets.
SHA-256 : `fba6e648c2569b115713060862166b80ffc38620929f2fb3e110868d61d6257e`.
- [Sanctuary-Test-beta.075.mrpack](../build/Sanctuary-Test-beta.075.mrpack), 9613850 octets.
SHA-256 : `65a7f40f8c10c58437980cf6ea40e45b5b8c9e1e75e8dbadcc981aa5f41e19fd`.
## beta.074 — autonomie des familiers
[Contrat et vérifications](familiar-autonomy-beta074.md). Quatre comportements
spontanés distincts, garde locale et ordres H bref/maintenu. Textures beta.070.
Trois parcours client natifs et `check build assemblePack assembleTestPack`
réussis. JAR, 1 689 classes et sources comparés ; neuf archives précédentes
conservées. Aucun déploiement sur le canal ou dans une instance personnelle.
- [Sanctuary-beta.074.mrpack](../build/Sanctuary-beta.074.mrpack), 9595113 octets.
SHA-256 : `cc73e93c2ae17d6b52b6c8dd53639771645c5e5becd848e2c76765e32c919c5d`.
- [Sanctuary-Test-beta.074.mrpack](../build/Sanctuary-Test-beta.074.mrpack), 9614048 octets.
SHA-256 : `0851e10350788b76efa96ec1a50db951a74200213399e2a4216f8d5b1b8fafcb`.
[Reçu de vérification](../build/autonomy074-artifact.json).
## beta.073 — arènes et paris
Livraison locale isolée sur Minecraft 26.3-pre-2. Le resource pack reste
beta.070. [Contrat du ticket](arenas-beta073.md). Aucun canal distant ni aucune
instance personnelle n'est modifié. Client natif, `check build`,
assemblage et vérification des archives réussis.
- [Pack normal beta.073](../build/Sanctuary-beta.073.mrpack), 9 592 283 octets.
SHA-256 : `9ef9c1c2f156d1eb9dcb31ae0f0364a798e46867ce86d56ae25fa2ae5c5105ca`.
- [Monde plat rapide beta.073](../build/Sanctuary-Test-beta.073.mrpack), 9 611 218 octets.
SHA-256 : `5394215474faea6a321cf44d64c01acaaa7fda554448110a3c8b47dcddf2f9c7`.
- [Contrôles et provenance](../build/arena073-artifact.json).
## beta.072 — statues et plans natifs
[Contrat, formats et essais](statues-plans-beta072.md). Outil K intégré à
Sanctuary, création de statues à partir des modèles et textures du jeu,
import/export de plans, aperçu et collage créatif contrôlé côté serveur.
Sans dépendance Litematica/MaLiLib ; textures beta.070 conservées.
- [Pack normal beta.072](../build/Sanctuary-beta.072.mrpack), 9 534 082 octets.
SHA-256 : `ccadfa614595f4409cb8eab9c795f112e65854c8e84864c8a8fb67dbc2de450e`.
- [Monde plat rapide beta.072](../build/Sanctuary-Test-beta.072.mrpack), 9 553 013 octets.
SHA-256 : `cb1786ffbf0290fc2cc5437647423e96abee8bca7737537aecbb12de98aee811`.
Reçu `build/statues-research/artifacts.json` : 1 671 classes, 758 sources Java,
150 ressources graphiques conformes ; index et ZIP vérifiés. Client natif,
sept tests serveur et 130 tâches Gradle réussis. Archives locales uniquement ;
aucun canal public ni instance personnelle modifié.
## beta.071 — personnalités et montures
[Contrat, audit et tests](familiar-personality-beta071.md). Quatre caractères,
poursuite plus vive, araignées grimpantes, Nautilus qui préserve lair du
cavalier et Strider à la surface de la lave. Construction depuis les sources
isolées de la 71 pour préserver le chantier simultané de la 72.
- [Pack normal beta.071](../build/Sanctuary-beta.071.mrpack), 9462323 octets.
SHA-256 : `1ab36663fe24313597c5328170a3eb2ca2e33bfdfc4c6fbc85c1f5c03102628c`.
- [Monde plat rapide beta.071](../build/Sanctuary-Test-beta.071.mrpack), 9481258 octets.
SHA-256 : `06309f4cd382053fb42d80c3f131152932e07523ace799c50d1d899026029a92`.
Reçu `build/familiar071-artifact.json` : 1 651 classes conformes, seules les
classes du familier prévues par le ticket changent. Les 150 ressources de la
beta.070, JEI, les autres ressources et les archives précédentes sont intacts.
Aucun canal public ni instance personnelle modifié.
## beta.070 — resource pack embarqué
[Contrat et vérifications](resourcepack-beta070.md). La source versionnée du
pack est embarquée dans le JAR et activée par défaut via Fabric ; aucun fichier
doptions personnelles nest distribué. Les variantes normal/Test et le ZIP
autonome utilisent les mêmes 150 ressources, comparées octet pour octet.
- [Pack normal beta.070](../build/Sanctuary-beta.070.mrpack), 9451989 octets.
SHA-256 : `9a582e17394bcb7327bd3b5a5a789b297431e41bf43409ed117fac728bbda46c`.
- [Monde plat rapide beta.070](../build/Sanctuary-Test-beta.070.mrpack), 9470924 octets.
SHA-256 : `5732e570c1af20f4d4896292c51bb38fb5c642d0ec81c6a1273ba27318938cd4`.
- [Resource pack autonome beta.070](../build/Sanctuary-Resource-Pack-beta.070.zip), 3757596 octets.
SHA-256 : `fe81653d09584f67da5f56c4fd6c866c733ec81e07559a5f132a44d44bbaadb6`.
Source du resource pack : commit `85e062ed91c7`. Reçu
`build/resourcepack070-artifact.json`, 1 648 classes conformes et archives
beta.069 intactes. Aucun canal public ni instance personnelle modifié.
## beta.069 — ordres du familier sur H
[Contrat et vérifications](familiar-orders-beta069.md). Retour des ordres
sur H et du changement de main sur F ; G reste le pouvoir. Parcours client,
compilation générale et archives vérifiés.
- [Pack normal](../build/Sanctuary-beta.069.mrpack), 5726225 octets.
SHA-256 : `1c923452ff4d5d186309390fac3306b8e05162143c852619b7519741002aa165`.
- [Monde plat rapide](../build/Sanctuary-Test-beta.069.mrpack), 5745158 octets.
SHA-256 : `bed499fa29f4247a9e321207df503b3147b8b4fc8c8541d817cdfe2a2db9a945`.
Reçu `build/familiar069-artifact.json` : 1 647 classes conformes, seule
`FamiliarClient` change ; anciens packs conservés. Aucun canal public ni
instance personnelle modifié.
## beta.068 — animation du préchargement
[Contrat et vérifications](loading-animation-beta068.md). Petite ligne SGA
animée pendant lattente, étapes réelles et progression native conservées.
Client natif, comparaison de huit captures et compilation générale réussis.
- [Pack normal](../build/Sanctuary-beta.068.mrpack), 5726390 octets.
SHA-256 : `cfb55d414186e6be5f0e19770049158302d4fbf37c0fa696306acda428594a2e`.
- [Monde plat rapide](../build/Sanctuary-Test-beta.068.mrpack), 5745324 octets.
SHA-256 : `643913caf577ee61b23747f378e9a21f5b1a54531901791d47d8ad0a75531dff`.
Reçu `build/loading068-artifact.json` : 1 647 classes conformes ; archives
beta.067, introduction, cache, textures et JEI préservés. Aucun canal public
ni instance personnelle modifié.
## beta.067 — touche et vol du dragon
[Contrat et vérifications](familiar-controls-beta067.md). F ouvre les ordres,
le dragon regarde devant lui, le grand porté monte avec la touche de saut.
Client natif et compilation générale réussis.
- [Pack normal](../build/Sanctuary-beta.067.mrpack), 5724176 octets.
SHA-256 : `a0dd2dc94aee0418b6ec8e29c24c847506627d63f6d22f1980ef7eacf38bc3fa`.
- [Monde plat rapide](../build/Sanctuary-Test-beta.067.mrpack), 5743110 octets.
SHA-256 : `e2605534bbe7eed0559304f7c2cb9bd6618178eb64b042e45ff2aa596f0bec22`.
Reçu `build/familiar067-artifact.json` : 1 646 classes compilées conformes ;
archives beta.066, textures et JEI inchangés. Aucun déploiement personnel
ni modification du canal public.
## beta.066 — cosmétiques animés sans doublon
[Contrat et vérifications](head-render-beta066.md). Le rendu sépare les parties
fixes des blocs portés de leur animation native : plus de second coffre fermé
ou de cloche immobile. Parcours client et compilation générale réussis.
- [Pack normal](../build/Sanctuary-beta.066.mrpack), 5721524 octets.
SHA-256 : `04e584ba7047e87e66316ba6e06f9d809e2813a6f4227067f20ca2ace7239f3a`.
- [Monde plat rapide](../build/Sanctuary-Test-beta.066.mrpack), 5740457 octets.
SHA-256 : `de6563aa48dededb6c1afa967987bd25e5134644596ea1908b1fa1768463aae5`.
Reçu `build/cosmetic066-artifact.json`. Archives beta.065, textures et JEI
préservés ; seule la source principale `HeadCosmeticModels` change.
Aucun canal ni instance personnelle modifié.
## beta.065 — collisions des bateaux
[Contrat et vérifications](boat-collisions-beta065.md). Les coques complètes et
les virages respectent les murs. 88 variantes et seize parcours pilotés en eau
ou en vol vérifiés. Build et comparaison des archives réussis.
- [Pack normal](../build/Sanctuary-beta.065.mrpack), 5721084 octets.
SHA-256 : `71ff1736817e2953f88a8afda5858b2e7ae23c360efdb7e25f149cbff049d764`.
- [Monde plat rapide](../build/Sanctuary-Test-beta.065.mrpack), 5740018 octets.
SHA-256 : `4c4931146a05d6f57e9abf2303b039ab7a7bc16471a0b864873b86bef297431d`.
Reçu `build/boat065-artifact.json`. Archives beta.064 intactes ; seul
`CrewBoat` change dans le code principal. Aucun canal ni instance personnelle
modifié.
## beta.064 — cache et préparation du monde
[Contrat et vérifications](loading-cache-beta064.md). Plans dérivés réutilisés
à la réouverture, étapes réelles sur fond uni, portail de préparation supprimé.
Introduction complète conservée. Build et parcours client natif réussis.
- [Pack normal](../build/Sanctuary-beta.064.mrpack), 5720330 octets.
SHA-256 : `c05b78e2caf27d52cb318979ee66e19e5bca9c90673f8868ae50f85f0e2ff2c0`.
- [Monde plat rapide](../build/Sanctuary-Test-beta.064.mrpack), 5739264 octets.
SHA-256 : `962800291875ab5bebda1abaedc798e7cf110ba0ef13ac168a0b85b3b2c19d24`.
Reçu `build/loading064-artifact.json` : 1 645 classes conformes au build,
archives beta.063 préservées. Aucun canal ni instance personnelle modifié.
## beta.061 — musique au portail et montures colossales
[Contrat et vérifications](arrival-mount-beta061.md). Musique démarrant au
+29
View File
@@ -0,0 +1,29 @@
# beta.075 — bibliothèque de plans
Branche `codex/plans-library-beta075`.
Dans **K → Bibliothèque**, le bouton « Rechercher sur Minecraft Schematics »
est retiré à la demande du créateur. Ses libellés FR/EN inutilisés sont supprimés.
Le placement des autres widgets se réajuste avec la disposition native.
Louverture du dossier, lactualisation, le glisser-déposer et limport des
plans locaux conservent leur fonctionnement. Aucun format, monde, texture ou
identifiant de contenu ne change. Minecraft 26.3-pre-2, resource pack beta.070.
## Vérifications
`check build assemblePack assembleTestPack` réussit en 2 min 39 s, 124 tâches.
Les 1 689 classes du JAR correspondent au build ; seul PlansScreen change
par rapport à beta.074. Les archives normal/Test et les sources sont vérifiées,
le lien est absent du bytecode et onze archives précédentes restent identiques.
Reçu : `build/library075-artifact.json`. Aucun nouvel essai client graphique.
Le serveur dédié reste
exclu conformément au refus daccepter son EULA (`-x :sanctuary:runGameTest`).
Aucun déploiement dans une instance personnelle.
## Archives
- [Sanctuary-beta.075.mrpack](../build/Sanctuary-beta.075.mrpack), 9594917 octets.
SHA-256 : `fba6e648c2569b115713060862166b80ffc38620929f2fb3e110868d61d6257e`.
- [Sanctuary-Test-beta.075.mrpack](../build/Sanctuary-Test-beta.075.mrpack), 9613850 octets.
SHA-256 : `65a7f40f8c10c58437980cf6ea40e45b5b8c9e1e75e8dbadcc981aa5f41e19fd`.
+13
View File
@@ -306,6 +306,19 @@ jusqu'à ce ticket. Mini-carte, marqueurs, partage, recettes, plans et autres
aptitudes gardent leurs tickets propres. Leur absence ne bloque pas le New
Game+ lorsque les six compétences sont au maximum.
Le [cadrage CLAIM-01 — titres de propriété temporaires](property-titles-ticket.md)
précise les décisions du 15 septembre 2026 : un titre couvre un seul chunk ;
il faut renommer l'objet puis faire un clic droit pour revendiquer le chunk.
Un autre titre portant le même nom permet d'étendre la même propriété,
y compris à un chunk séparé, sans revendiquer les chunks intermédiaires.
Les titres restent attachés aux chunks ; une réserve de **saphirs consommés
successivement** entretient leur protection en temps réel, même lorsque le
joueur est déconnecté, tant que le serveur tourne. À la fin de la période
payée, sans assez de saphirs pour renouveler, la protection disparaît
immédiatement. Acquisition des titres, tarifs d'entretien, paliers, durée des
périodes, traitement des arrêts du serveur et droits de réservation sans
protection restent à décider ; ce mécanisme n'est pas encore implémenté.
## Références et validation des livraisons
La référence historique est consultée en lecture seule dans `../26.2/sanctuary` :
+175
View File
@@ -0,0 +1,175 @@
# CLAIM-01 — Titres de propriété temporaires
**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
branche de travail pour préserver les travaux en cours. Aucun numéro de
livraison réservé et aucune issue distante publiée.
## Intention et décisions confirmées
Le joueur achète des **titres de propriété temporaires** donnant des privilèges
sur un terrain. Il choisit un niveau de protection et constitue une réserve
de saphirs pour l'entretenir. Le titre reste attaché au chunk ; la protection
accordée est temporaire et renouvelable.
**Un titre couvre un seul chunk**, soit une emprise horizontale de 16 × 16
blocs alignée sur la grille Minecraft. Un même titre ne couvre pas plusieurs
chunks à la fois. Son entretien utilise ensuite des saphirs.
**Le titre est un objet à renommer avant utilisation.** Le joueur fait un
clic droit avec ce titre dans le chunk à revendiquer. Le nom donné au titre
sert de nom à la propriété ; utiliser un autre titre portant le même nom
dans un autre chunk étend cette propriété. Chaque chunk ajouté nécessite
son propre titre, conformément à la règle « un titre = un chunk ».
**Les chunks d'une même propriété peuvent être séparés** : aucune contiguïté
n'est requise. Les chunks situés entre eux ne sont pas revendiqués par cette
extension. Le moyen de renommer l'objet reste à préciser.
**Les saphirs sont consommés successivement pour entretenir le niveau de
protection choisi. Les titres restent attachés aux chunks.** À niveau et
emprise identiques, ajouter des saphirs prolonge l'entretien disponible.
Cette décision remplace la consommation successive de titres envisagée au
début de la discussion.
**L'entretien se fait en temps réel**, y compris lorsque le joueur est
déconnecté, tant que le serveur tourne. Il ne dépend pas du temps de présence
du titulaire. Le traitement du temps écoulé pendant un arrêt du serveur reste
à décider séparément.
**La protection disparaît immédiatement à épuisement de la couverture** :
lorsque la dernière période payée se termine et que la réserve ne contient
pas assez de saphirs pour renouveler, aucun délai de grâce ni baisse
progressive ne s'applique. Une réserve vide ne raccourcit pas la période
déjà payée. Le titre reste attaché au chunk ; la possibilité pour un autre
titulaire de revendiquer ce chunk sans protection reste à décider.
La protection envisagée rend les blocs plus longs à miner par les autres.
Le créateur évoque un premier ralentissement doublé et une protection pouvant
aller jusqu'à des blocs presque incassables, voire incassables. Les valeurs,
les bénéficiaires et la limite exacte restent à décider.
## Modèle proposé à préciser
Séparer dans le fonctionnement et l'interface :
- **Niveau choisi** : résistance appliquée et coût d'entretien associé.
- **Réserve** : saphirs disponibles pour les renouvellements successifs.
- **Échéance en cours** : moment de la prochaine consommation ou fin de
couverture, selon le contrat temporel à retenir.
Proposition : un niveau plus élevé coûte davantage de saphirs. Le coût et
la durée d'une période payée ne sont pas fixés ; l'emprise est d'un chunk
par titre. La gestion des réserves pour plusieurs chunks reste à préciser.
La consommation en temps réel est confirmée ; une consommation
supplémentaire provoquée par les attaques n'a pas été demandée.
Pour le premier ralentissement, l'interprétation proposée est un **temps de
minage multiplié par deux** pour un joueur non autorisé, à bloc, outil et
conditions identiques. Cela reste à confirmer. Les privilèges supplémentaires
du titulaire ne sont pas encore définis.
## Économie : entretien en saphirs, acquisition à définir
Le **saphir est retenu pour entretenir la protection des titres déjà posés**.
La [vision](vision.md#les-trois-gemmes) lui attribue déjà la réservation et la
sauvegarde de quantités limitées d'objets dans le catalogue. Ce nouvel usage
prolonge son rôle de conservation, sans supposer cette économie déjà disponible.
Restent à choisir : monnaie et prix d'achat initial des titres, lieu et geste
d'achat, obtention effective des saphirs, tarifs d'entretien, partage et
éventuel transfert. Le titre utilisable en jeu est un objet renommable ; le
registre serveur et le dépôt des saphirs dans la réserve restent à définir.
## Arbitrages nécessaires au premier lot jouable
- Emprise d'un chunk par titre confirmée ; préciser la portée verticale,
les dimensions concernées, le nombre maximal de chunks par titulaire et
les règles de chevauchement. Choisir une réserve par chunk ou une réserve
commune avec des règles explicites d'affectation des saphirs et de
renouvellement lorsque le solde ne suffit pas pour tous les chunks.
- Création et extension par titre renommé puis clic droit confirmées ;
préciser le moyen de renommage, les noms admis, leur comparaison et les
collisions entre titulaires. Les chunks séparés sont autorisés. Définir
comment départager le chunk du joueur et celui du bloc visé à une frontière,
et l'effet d'un clic dans un chunk déjà revendiqué.
Le nom sert au regroupement ; le serveur doit aussi vérifier les droits
d'extension pour qu'un nom identique ne permette pas de prendre le contrôle
de la propriété d'un autre titulaire.
- Titulaire : joueur, faction ou les deux ; droits des membres, invités et
tiers, et rapport éventuel au prestige. Demeure mesure l'habitation et ne
vaut pas attribution d'un titre.
- Paliers de résistance, privilèges et incassabilité éventuelle ; articulation
avec les outils, enchantements et aptitudes de minage existants.
- Coût en saphirs et durée réelle de chaque période selon le niveau ;
financement de la première activation ; traitement des chunks déchargés
et du temps écoulé pendant les arrêts du serveur. La déconnexion du joueur
ne suspend pas l'entretien.
- Changement de niveau : prise d'effet, traitement de la période payée et du
temps restant, pour éviter de gagner de la couverture par des changements
répétés.
- Avertissement avant épuisement, droits de réservation d'un titre sans
protection et réactivation après réapprovisionnement. Le titre reste attaché
au chunk et la protection disparaît immédiatement en fin de couverture.
- Abandon, transfert, conflits de réservation et devenir du stock restant.
- Portée de la protection face aux explosions, pistons, fluides, mobs,
inventaires et actions groupées ; distinguer les droits d'interaction de la
résistance au minage.
## Réception de la future implémentation
Le premier lot doit permettre d'obtenir des titres par la voie retenue,
réserver un terrain, choisir sa protection, approvisionner la réserve en
saphirs et observer leur consommation successive ainsi que l'effet sur le
minage, avec conservation du titre attaché au chunk.
- Renommer un titre « Verger » et faire un clic droit dans un chunk libre
crée la propriété « Verger ». Utiliser un autre titre de même nom dans un
second chunk admissible étend la même propriété à ce chunk. Une autre
propriété, nommée différemment, conserve son emprise.
- Répéter l'extension dans un chunk séparé du premier par des chunks libres :
les deux chunks appartiennent à la même propriété, sans revendiquer ni
protéger les chunks intermédiaires.
- Un titre non renommé ne revendique aucun chunk et explique le renommage
nécessaire. Un refus d'extension ou un conflit ne dépense pas de titre et
ne modifie pas de terrain ; vérifier les collisions de noms entre joueurs.
- Deux joueurs vérifient les droits et le ralentissement de chaque palier,
à conditions identiques, ainsi que les limites du terrain.
- Un titre couvre exactement le chunk désigné : sa protection ne déborde
pas sur le chunk voisin. Couvrir deux chunks nécessite des titres distincts ;
renouveler le même chunk ne modifie pas son emprise.
- Ajouter des saphirs à niveau et emprise constants prolonge l'entretien sans
modifier la résistance choisie. Un renouvellement conserve la couverture
et le titre attaché, sans exiger un nouveau titre pour le même chunk.
- Déconnecter le titulaire en laissant le serveur tourner : à son retour,
la réserve et la couverture reflètent le temps réel écoulé, y compris si
plusieurs renouvellements en saphirs ont eu lieu pendant son absence.
- Épuisement, réapprovisionnement et changement de niveau suivent les règles
arrêtées, avec un état et une échéance compréhensibles en FR/EN.
- Avec une réserve vide ou insuffisante pour renouveler, vérifier la
protection jusqu'à la fin de la période payée, puis sa disparition immédiate
à l'échéance, y compris lorsque le titulaire est déconnecté. Le titre reste
attaché ; le minage retrouve les règles ordinaires applicables, sans
résistance résiduelle provenant des titres.
- Achat, consommation et droits font autorité côté serveur. Des requêtes
répétées ou simultanées et un redémarrage ne dupliquent ni dépense, ni titre,
ni saphir, et ne remettent pas gratuitement la durée à zéro.
- Un contrat de sauvegarde et de migration explicite précède toute donnée
persistante. Le chargement d'une ancienne partie n'attribue aucun terrain
implicitement ; aucune régénération de chunks n'est nécessaire.
- Tester les interactions retenues ci-dessus et les actions groupées, puis
lancer `./gradlew check build`. Une livraison de distribution exige aussi
`./gradlew assemblePack` et le [versionnement](versioning.md) mod/pack.
## État actuel
L'emprise est fixée à un chunk par titre. Le titre est renommé puis utilisé
par clic droit ; un même nom permet d'étendre la propriété à un autre chunk.
Les chunks d'une même propriété peuvent être séparés.
Les titres restent attachés aux chunks. La consommation successive de saphirs
en temps réel pour maintenir un niveau choisi, y compris lorsque le joueur est
déconnecté, est confirmée.
La protection disparaît immédiatement lorsque la couverture est épuisée.
Les autres arbitrages sont explicitement ouverts ou proposés ci-dessus.
Ce cadrage est documentaire : aucun code, binaire, monde ou format de
sauvegarde modifié ; aucun test de gameplay exécuté pour ce ticket.
+90
View File
@@ -0,0 +1,90 @@
# beta.070 — resource pack versionné et actif par défaut
Branche `codex/resourcepack-beta070`.
## Source et version
La source unique est `ressources-pack/sanctuary/`. Le dossier fourni
`Sanctuary-Resource-Pack-base-26-3` contient 107 fichiers, dont 85 PNG.
Tous ses assets sont repris sans retouche. Sept images diffèrent de la base :
les six états `hud/food_*` et latlas `sanctuary/textures/gui/food_details.png`.
Les 47 cœurs vanilla déjà présents dans la source restent identiques.
Aucun remplacement des bulles de respiration.
`ressources-pack/sanctuary/releases/beta.070.json` fixe les empreintes des
150 fichiers distribués, la provenance des 107 fichiers fournis et le format
exact **97.1** de Minecraft **26.3-pre-2**. Les guides ont été actualisés ; le
manifeste Minecraft porte le numéro beta.070 et une description FR/EN.
Le numéro graphique `resource_pack_version` est distinct des versions du mod
et du modpack. Un correctif de code ultérieur peut garder ces mêmes textures.
Une retouche graphique demande un nouveau manifeste de release, en conservant
celui des versions précédentes. `verifyResourcePack` détecte toute dérive des
fichiers, des empreintes, des CRC PNG, des JSON ou du format.
## Chargement par défaut
Gradle copie directement cette source dans le mod à
`resourcepacks/textures/`. LAPI Fabric 0.160.0+26.3 enregistre le pack natif
`sanctuary:textures` avec `DEFAULT_ENABLED`. Il est donc actif dès sa première
apparition, y compris dans une instance existante qui découvre ce pack.
Le joueur garde la possibilité de le désactiver ou de placer un autre pack
au-dessus ; son choix nest pas réinitialisé à chaque lancement.
Aucun `options.txt` livré, ni écrasement des réglages personnels. La version
normal et la version Test embarquent le même resource pack dans le même mod.
Le ZIP `Sanctuary-Resource-Pack-beta.070.zip` est également exporté pour un usage
séparé ; il nest pas nécessaire de lajouter une seconde fois dans Sanctuary.
Il conserve les namespaces natifs : la jauge dynamique, les aperçus alimentaires
et les infobulles utilisent leurs chemins de textures existants.
## Vérifications natives
`ResourcePack070ClientChecks` réussit en **46 s** : activation au démarrage
sans modifier la sélection, format reconnu compatible, priorité effective et
égalité des octets chargés pour les sept PNG modifiés, respiration native,
désactivation conservée après redécouverte des packs, réactivation.
Captures inspectées : le pack est dans la colonne « Selected », la description
porte beta.070, les sprites de faim et de saturation proviennent des nouveaux
dessins dans latlas natif. Preuves : `build/resourcepack070-client.log` et
`build/resourcepack070-evidence/`. Pas de nouveau monde ni de serveur dans ce
test ; les formules alimentaires ne sont pas modifiées.
```sh
JAVA_HOME=/Users/koka/Library/Java/JavaVirtualMachines/temurin-25.jdk/Contents/Home \
./gradlew :sanctuary:runClientGameTest -x :sanctuary:runGameTest \
-PsanctuaryClientTests=true -PsanctuaryResourcePack070ClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryClientNoVsync=true
./gradlew verifyResourcePack assembleResourcePack
```
## Livraison vérifiée
Source du resource pack enregistrée dans le commit `85e062ed91c74fcc3695e1e224fe51ac8fd010e0`.
Les **150 fichiers distribués** ont été relus dans lobjet Git et leurs
empreintes correspondent au manifeste. Ce commit ne contient que la source
du resource pack ; les chantiers de code préexistants restent dans larbre de
travail. Aucun push ni tag de lensemble des mods na été créé.
`check build assemblePack assembleTestPack` réussit en **2 min 28 s**, 124 tâches,
avec les profils client/test rapide et `-x :sanctuary:runGameTest`. Le serveur
dédié reste exclu conformément au choix de ne pas accepter son EULA.
Les deux MRpacks contiennent les mêmes textures que le ZIP et que la source.
**1 648 classes** conformes à la compilation, seuls `SanctuaryKnowledgeClient`
et `SanctuaryResourcePack` changent par rapport à beta.069. Les autres ressources
existantes et JEI restent identiques ; deux libellés FR/EN et le pack embarqué
sont ajoutés. Les archives beta.069 conservent leurs empreintes.
Reçu : `build/resourcepack070-artifact.json`.
- [Pack normal beta.070](../build/Sanctuary-beta.070.mrpack), 9451989 octets.
SHA-256 : `9a582e17394bcb7327bd3b5a5a789b297431e41bf43409ed117fac728bbda46c`.
- [Monde plat rapide beta.070](../build/Sanctuary-Test-beta.070.mrpack), 9470924 octets.
SHA-256 : `5732e570c1af20f4d4896292c51bb38fb5c642d0ec81c6a1273ba27318938cd4`.
- [Resource pack autonome beta.070](../build/Sanctuary-Resource-Pack-beta.070.zip), 3757596 octets.
SHA-256 : `fe81653d09584f67da5f56c4fd6c866c733ec81e07559a5f132a44d44bbaadb6`.
Aucun déploiement personnel, aucune publication du canal, aucun monde modifié.
+50
View File
@@ -0,0 +1,50 @@
# RP-HUD-01 — base de textures de vie et de faim
Ticket du 15 septembre 2026, branche `codex/resourcepack-hud-base`.
Demande du créateur : préparer les textures Minecraft d'origine pour qu'il
dessine ensuite lui-même la vie, la faim et les interfaces Sanctuary.
## Résultat
- 53 PNG ajoutés au resource pack existant : les 47 sprites de `hud/heart/`
et les six sprites `hud/food_*`, extraits sans retouche du client 26.3-pre-2.
- Manifeste corrigé du format provisoire `0` au format exact **97.1**,
avec `min_format` et `max_format`.
- [Guide de retouche FR/EN](../ressources-pack/sanctuary/HUD.md), correspondance
des variantes et recette d'export ZIP ; empreintes d'origine dans
[HUD-SOURCES.json](../ressources-pack/sanctuary/HUD-SOURCES.json).
- Menus, panorama, progression, aptitudes et atlas alimentaire existants
conservés. La respiration utilise toujours les ressources vanilla.
Le pack est une base graphique séparée du mod. Aucun code Java, identifiant,
format de sauvegarde ni manifeste packwiz ne change dans ce ticket ; aucun
nouveau binaire du mod n'est livré, donc aucun compteur beta n'est consommé.
Les modifications beta.062/beta.063 déjà présentes restent hors de ce lot.
## Vérification
Contrôles effectués sur la base et son export local :
- Les 53 sprites correspondent octet pour octet au client officiel ; tous
mesurent 9 × 9, conservent la transparence et passent le contrôle des CRC
PNG et la décompression des données d'image.
- Les 19 fichiers JSON et métadonnées se lisent correctement. Le format 97.1
correspond au `version.json` du client ; les champs du manifeste suivent
son lecteur `PackFormat`.
- 121 fichiers préexistants sont inchangés, dont toutes les textures déjà
présentes dans le resource pack et les travaux hors périmètre. Les seuls
fichiers de ce relevé modifiés sont le manifeste et les guides ciblés.
- Le ZIP contient 154 fichiers, avec `pack.mcmeta`, `pack.png` et `assets/`
à la racine. Son intégrité et l'égalité de chaque entrée avec sa source
sont vérifiées ; aucun fichier Finder `.DS_Store` n'est exporté.
Export local :
[`Sanctuary-Resource-Pack-base-26.3-pre-2.zip`](../ressources-pack/Sanctuary-Resource-Pack-base-26.3-pre-2.zip),
3 761 553 octets. SHA-256 :
`21b2ce42e9c381191508c93929d5ce02ee70beeeb519cf58a9ae6b3acd35f107`.
Reçu local ignoré : `build/resourcepack-hud-base/verification.json`.
Aucun essai de rendu en jeu ni installation personnelle effectué. Les
commandes Gradle ne sont pas lancées pour ce lot de PNG et de documentation :
le code et la distribution packwiz sont inchangés. Le contrôle visuel après
les futures retouches du créateur est décrit dans le guide HUD.
+192
View File
@@ -0,0 +1,192 @@
# PLAN-01 — statues et plans légers — beta.072
Branche `codex/statues-plans-beta072`. Ticket ouvert le 15 septembre 2026.
**Implémenté, essais natifs réussis et archives locales beta.072 vérifiées.**
## Demande et choix retenu
Extraire les modèles et textures réellement utilisés par Minecraft pour créer
des statues en blocs, avec une palette de matériaux choisie par couleur.
Le créateur demande un outil Sanctuary léger qui couvre l'essentiel des plans
partagés en ligne. Il a retenu une implémentation native compatible avec les
fichiers Litematica, sans embarquer Litematica ou MaLiLib.
Parcours : modèle → hauteur et palette → plan → aperçu positionnable et
orientable → placement créatif, ou construction manuelle guidée en survie.
Les plans importés partagent ce dernier parcours. Une bibliothèque locale,
l'export `.litematic`, les couches et le décompte des blocs complètent l'outil.
Le lien de recherche Minecraft Schematics livré en beta.072 est retiré en
[beta.075](plans-library-beta075.md). La bibliothèque utilise les fichiers locaux.
## Utilisation
1. Appuyer sur **K** (raccourci modifiable dans les contrôles).
2. Dans **Statue**, choisir une espèce, ou le mob visé au moment d'ouvrir
l'écran. Régler la hauteur de 4 à 96 blocs et la palette : mélange, béton
ou laine. La géométrie et les textures viennent du rendu natif et des
ressources actives ; les pixels transparents ne deviennent pas des blocs.
3. Dans **Bibliothèque**, charger un fichier local ou le déposer sur l'écran.
Les dossiers `schematics/` et `schematics/sanctuary/` sont consultés.
**Ouvrir le dossier** permet dy déposer des plans ; **Actualiser** relit
les fichiers disponibles.
4. Dans **Construction**, ancrer le plan au point visé, régler ses coordonnées,
tourner de 90°, isoler une couche et consulter les blocs restants.
Les blocs corrects disparaissent de l'aperçu ; les conflits sont rouges.
5. En créatif, **Placer** puis confirmer dans le jeu. En survie, poser les
blocs normalement avec son inventaire ; le compteur suit l'avancement.
**Exporter** écrit un `.litematic` dans `schematics/sanctuary/`, sans écraser
les exports précédents.
L'aperçu est local et se ferme au changement de dimension ou à la déconnexion.
Exporter avant de quitter conserve le plan. Le serveur Sanctuary est requis
pour le collage créatif ; un aperçu seul ne donne aucun droit de placement.
## Compatibilité implémentée
- Minecraft **26.3-pre-2**, Java 25, versions Fabric du dépôt.
- `.litematic` versions 4 à 7 en lecture, version 6 en écriture ; Sponge
`.schem` v2/v3 en lecture ; structures vanilla `.nbt` en lecture. Fichiers
NBT compressés gzip. Les palettes classiques `Name`/`Properties` et les clés
natives 26.3 `id`/`properties` sont lues ; l'export Litematic conserve le
contrat classique, indépendant de la version Minecraft.
- Refus explicite des blocs ou propriétés inconnus, des formats non pris en
charge et des données d'une version Minecraft plus récente. Aucun remplacement
silencieux par de l'air ; aucune migration automatique d'anciens identifiants.
- Les anciennes `.schematic` à identifiants numériques nécessitent une
conversion externe. Ni archive de monde ni ZIP n'est un plan importable.
- Import des blocs uniquement : les inventaires, commandes, entités vivantes,
ticks différés et biomes d'un fichier tiers ne sont pas exécutés/importés.
## Contrat de monde et de sauvegarde
Les plans sont des fichiers clients indépendants ; aucun format de sauvegarde
Sanctuary ni aucune génération n'évolue. Aucun monde personnel n'est ouvert
par le développement. La conversion utilise un modèle de rendu, sans faire
apparaître ni supprimer un mob. Un aperçu ne modifie pas le monde.
Le collage créatif demande une action explicite en jeu. Le serveur vérifie le
mode réel, la taille, la portée, les chunks déjà chargés, la frontière du monde,
les permissions et l'absence de blocs occupés. Un opérateur en survie reste en
survie. Les blocs d'air du plan ne servent jamais à effacer le terrain.
En survie, les poses et la consommation passent par Minecraft normalement.
## Limites de cette livraison
- Les statues sont des enveloppes creuses. Les couleurs sont approchées par
celles des textures moyennes des matériaux ; la forme dépend de la hauteur
choisie. Les effets, étiquettes, laisses et couches d'objets/blocs ne font pas
partie de la capture. Les textures dynamiques absentes des ressources et les
UV d'atlas spéciaux demandent encore un adaptateur.
- L'aperçu utilise des faces colorées et les formes de sélection natives,
sans reproduire toutes les textures et l'éclairage d'un bloc posé. Il dessine
à 96 blocs du joueur et recalcule les blocs restants toutes les dix ticks.
Le traitement des fichiers et la conversion géométrique passent par un
unique fil de travail ; masquer le plan arrête son actualisation périodique.
- Plafonds : 256 blocs par axe, 32 768 blocs non vides, 2 097 152 cellules de
volume ; fichier compressé de 8 Mio et lecture NBT de 32 Mio au maximum.
La capture refuse plus de 16 384 faces et la conversion plus de 8 millions
d'échantillons. Un gros build communautaire peut dépasser ces limites.
- Collage à moins de 192 blocs, dans des chunks chargés. Pas d'effacement,
de remplacement du terrain occupé, de blocs administratifs ou sans objet
associé. Le serveur consulte les permissions et le veto Fabric d'utilisation
des blocs. La physique et les mises à jour des voisins reprennent ensuite.
- Les matériaux comptent des états de blocs. Portes, lits et dalles doubles
peuvent donc demander un nombre d'objets différent du nombre affiché.
- Aucun import d'inventaire, de NBT de bloc ou d'entité ; aucun miroir, collage
avec remplacement, remplissage automatique en survie ou catalogue web intégré.
- Aucun benchmark comparatif avec Litematica n'est revendiqué. La réduction
de périmètre et l'absence de dépendance supplémentaire sont vérifiables ;
elles ne constituent pas une mesure de gain de FPS.
## Sources de compatibilité
Vérifiées le 15 septembre 2026 :
- [Litematica](https://modrinth.com/mod/litematica/versions) publie des versions
26.2, sans release annoncée pour la cible exacte du dépôt.
- [Source 26.3](https://github.com/sakura-ryoko/litematica/tree/101ac3a23649f916cdcf05f30a5036f72706a096)
: cible 26.3-rc-1, rendu encore en chantier. Consultée pour le contrat du
format ; aucun JAR tiers supposé compatible n'est ajouté au pack.
- [Spécification Sponge v3](https://github.com/SpongePowered/Schematic-Specification/blob/master/versions/schematic-3.md).
- [Recherche Minecraft Schematics](https://www.minecraft-schematics.com/search/).
- [Litemapy](https://github.com/SmylerMC/litemapy), utilisé seulement pour écrire
des fixtures synthétiques indépendantes et relire l'export, hors distribution.
## Vérifications
Essais sur macOS, Java 25, Minecraft 26.3-pre-2 et Fabric API 0.160.0+26.3.
Tous les mondes utilisés sont des mondes de développement jetables.
- **Client natif réussi** : les 88 espèces à œuf d'apparition ont fourni leur
géométrie texturée. Un creeper a parcouru les vrais widgets de création,
l'aperçu, les quatre rotations, les matériaux, l'export, la confirmation et
le collage réseau. Le serveur et le client retrouvent les mêmes blocs.
Résultat : **10 × 32 × 15**, **1 542 blocs**, 22 entrées de palette avec l'air.
Seul ce modèle a fait l'objet du parcours complet de statue en jeu ; la
capture des 88 espèces ne vaut pas inspection visuelle de 88 statues.
- **Survie vérifiée** : un paquet de collage envoyé malgré le mode survie est
refusé ; l'interface ne propose pas le collage. Une pose au clic droit natif
consomme un objet et fait avancer le compteur. Interfaces FR/EN capturées ;
les 95 nouvelles clés de traduction sont présentes dans les deux langues.
- **Interopérabilité indépendante** : Litemapy 0.11.0b0 relit le fichier produit
en jeu et retrouve dimensions, états et total de 1 542 blocs. Ses trois
écrivains ont aussi produit les fixtures `.litematic`, Sponge et vanilla
du test serveur. La dépendance Python reste dans un environnement ignoré ;
seuls les petits exemples synthétiques en base64 sont versionnés.
- **Serveur et formats réussis** : les six GameTests du ticket et le test de
contrôle Fabric passent. Ils couvrent l'autorité créatif/survie, le refus
complet sur bloc occupé ou permission refusée, les blocs administratifs,
la portée, les régions négatives, les palettes traversant les mots de 64 bits,
les fichiers indépendants, les entrées invalides, la transparence, les UV et
les rotations.
- **Build et distribution réussis** : `check build assemblePack assembleTestPack`
termine en 3 min 8 s, 130 tâches. Les GameTests sont ciblés sur PLAN-01 dans
le harnais plat ; les contrôles Java/Python généraux restent exécutés. Ce
ciblage ne revendique pas une nouvelle validation de toute la génération.
Un premier lancement avait échoué au démarrage Java du test historique
`lostCityPlanner15Smoke` avec un message JavaFX ; le même test isolé puis
l'ensemble de la commande réussissent sans modification de ce code.
Commande du parcours client :
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryPlans072ClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryClientNoVsync=true
```
Commande de contrôle et d'assemblage finale :
```sh
./gradlew check build assemblePack assembleTestPack \
-PsanctuaryFocusedTests=plans072 -PsanctuaryAtlasOnly=true \
-PsanctuaryClientTests=true -PsanctuaryPlans072ClientTests=true \
-PsanctuaryQuickTests=true
```
Puis `packwiz modrinth export --output ../Sanctuary-beta.072.mrpack` dans
`build/packwiz/`, et l'équivalent `Sanctuary-Test-beta.072.mrpack` depuis
`build/packwiz-test/`.
Les captures et reçus locaux sont conservés dans
`build/statues-research/evidence/`, `client-tests.log` et `interoperability.json`
du même dossier de recherche. Ils ne sont pas ajoutés au pack.
## Archives locales
Le mod et les manifestes passent à **beta.072** ; les textures restent en
**beta.070**. Les 1 671 classes du JAR correspondent exactement aux classes
compilées, les 758 sources Java à l'archive des sources, et les 150 ressources
graphiques aux fichiers versionnés. Les deux MRpacks contiennent le même JAR
Sanctuary ; seul le profil Test ajoute le module de monde plat. Versions,
index packwiz, empreintes et intégrité ZIP sont vérifiés.
| Artefact | Octets | SHA-256 |
| --- | ---: | --- |
| [Pack normal](../build/Sanctuary-beta.072.mrpack) | 9 534 082 | `ccadfa614595f4409cb8eab9c795f112e65854c8e84864c8a8fb67dbc2de450e` |
| [Monde plat rapide](../build/Sanctuary-Test-beta.072.mrpack) | 9 553 013 | `cb1786ffbf0290fc2cc5437647423e96abee8bca7737537aecbb12de98aee811` |
| [Mod Sanctuary](../mods/sanctuary/build/libs/sanctuary-beta.072.jar) | 10 043 650 | `f324eba7046563e06671c27551497ad6254cac80a27b5adbf3887b20998f0325` |
Reçu : `build/statues-research/artifacts.json`. Les changements déjà présents
des beta.062 à beta.071 sont conservés. Aucun canal public, instance Prism ou
serveur personnel n'est synchronisé par cette préparation locale.
+60
View File
@@ -0,0 +1,60 @@
# beta.089 — Crash au contact du super piston
Branche : `codex/super-piston-collision-beta089`.
Signalement : crash beta.088 sous Minecraft 26.3-pre-2, `Ticking entity`,
pendant `LocalPlayer.moveTowardsClosestSpace``isSuffocating`.
## Cause et correction
Les propriétés de la tête et de la tige étaient copiées depuis le socle du
piston natif. Son prédicat de suffocation lit `PistonBaseBlock.EXTENDED`.
Cette propriété existe sur les socles, mais pas sur les pièces techniques :
la recherche d'un espace libre autour du joueur levait une exception lorsque
sa boîte de collision rencontrait `sanctuary:super_piston_head`.
La tige héritait du même défaut. Le prédicat de visibilité copié capture
aussi les propriétés du socle dorigine et relit ce même état : changer
uniquement la suffocation ne suffisait pas.
La tête et la tige utilisent des propriétés neuves, avec des prédicats
explicites de suffocation et doccultation désactivés. Elles conservent leurs collisions physiques de plaque et de tige,
leur résistance et leurs règles de mouvement. Les socles gardent le prédicat
natif, avec suffocation seulement lorsqu'ils sont rétractés.
Aucun changement d'identifiant, d'état de bloc, de schéma ou de sauvegarde.
Les pistons déjà assemblés restent compatibles : aucune reconstruction,
conversion ou suppression de blocs n'est nécessaire. Les textures et le
correctif de navigation du Fût beta.088 sont conservés.
## Validation ciblée
Le parcours client natif des super pistons est étendu pour contrôler tous
les états des deux socles, de la tête et de la tige, côté client et serveur :
suffocation, visibilité, support d'apparition et conduction redstone.
Le chemin natif `collidesWithSuffocatingBlock` est aussi exercé avec le joueur
sur les neuf cases de tête et les deux positions de tige de chaque variante,
dans chacune des six orientations.
Le défaut initial a été reproduit avec la même `IllegalArgumentException`
que dans le rapport : `build/piston089-before-client.log`. Le contrôle de
visibilité a ensuite révélé la capture des anciennes propriétés, corrigée
par les propriétés neuves décrites ci-dessus.
Le parcours complet passe en 1 min 25 s (`build/piston089-client.log`) :
864 états contrôlés côté client et serveur ; contact natif du joueur avec
les pièces ; deux variantes et six directions ; charge de 108 et refus de
109 blocs ; obstacles, inventaires, slime/miel, transport vertical d'une
entité, interruption, sérialisation, retour dans les chunks et démontage.
Les essais se déroulent dans un monde de développement neuf, graine 87.
`check build assemblePack assembleTestPack` réussit en 2 min 35 s
(124 tâches, 104 exécutées), avec `:sanctuary:runGameTest` explicitement exclu.
Journal : `build/piston089-check.log`. Le reçu `build/piston089-artifact.json`
vérifie les sources, le JAR, les archives et l'identité de tous les assets
avec beta.088. Les seules classes compilées modifiées sont `SuperPistons`
et ses classes internes (décalages de lignes de debug pour ces dernières).
Archives locales : `build/Sanctuary-beta.089.mrpack` et
`build/Sanctuary-Test-beta.089.mrpack`. Les archives beta.088 sont intactes.
Aucun déploiement sur Prism ou sur le canal publié, aucun serveur dédié
lancé, aucune EULA acceptée. Validation native sur macOS ; pas d'exécution
sur le poste Windows du signalement ni d'essai LAN à deux clients distincts.
+110
View File
@@ -0,0 +1,110 @@
# beta.087 — Super piston et super piston gluant
## Contrat de sauvegarde avant implémentation
Assemblage volontaire à la clé dorée d'un carré plein de neuf pistons
rétractés, identiques et orientés dans la même direction. Deux clics de
confirmation ; les six directions sont admises. Sans cette action, les
pistons restent indépendants. Aucun changement de la génération ni des
registres de Fourneau et de Fût.
Les neuf composants deviennent des blocs `sanctuary:super_piston` ou
`sanctuary:super_sticky_piston`, identifiés par leur orientation et leur
position dans le carré. Le composant central porte une BlockEntity dédiée,
schéma 1 : course engagée (0 à 3) et liste des déplacements natifs en cours.
Les états intermédiaires utilisent les moving pistons
natifs, avec leur état de bloc et progression sérialisés par Minecraft.
Les pièces de tête et de tige sont des blocs techniques sans objet récupérable.
Aucun inventaire ni copie de charge n'est conservé dans le contrôleur.
Un signal redstone sur un des neuf composants demande la sortie ; sa
coupure demande le retour. Trois pas animés d'un bloc. Chaque pas est planifié
entièrement avant mutation : obstacle intermédiaire, hauteur, frontière du
monde, charge et présence des chunks. Aucune charge de chunk forcée.
L'animation engagée finit avant d'entamer le pas suivant ou d'inverser.
Après rechargement, les animations natives finissent et le contrôleur reprend
à partir de l'état courant, sans rattrapage temporel ni restitution d'une
copie initiale. Les sauvegardes suivent l'atomicité native des chunks ; pas de
journal inter-chunks pour un crash brutal pendant une sauvegarde.
La charge commune conserve les règles natives d'indéplaçabilité et les
liaisons slime/miel. Une poussée bloquée suspend l'ensemble de la tête ;
une traction impossible laisse la charge en place et permet au gluant de
rentrer, comme un piston natif. Les coffres et autres inventaires indéplaçables restent en place. Les blocs
que les pistons natifs cassent, notamment les boîtes de Shulker, déposent
leur objet avec son contenu.
Le piston normal ne tire pas au retour. Les collisions et le déplacement des
entités utilisent les moving pistons natifs.
Maj-clic avec la clé dissocie une machine rétractée et désalimentée. Une
machine active demande de couper son alimentation et de la laisser rentrer.
Casser une pièce dissocie la structure : les bases restantes redeviennent
leurs pistons d'origine, les pièces techniques disparaissent sans butin ;
seuls les composants effectivement cassés déposent leur piston. Les charges
déjà déplacées restent à leur position actuelle, les animations de charge
engagées finissent normalement. Une moitié déchargée attend son chargement
pour valider ou nettoyer ses pièces ; aucun bloc étranger n'est écrasé.
## Valeurs et utilisation
Charge totale acceptée : **108 blocs distincts**, commune aux neuf cases,
liaisons slime/miel incluses. Ce n'est pas une limite de 12 par colonne.
Les neuf pistons doivent être du même type, orientés pareil, rétractés et
sans alimentation. La clé dorée montre d'abord le carré ; un deuxième clic
sous dix secondes confirme l'assemblage. Maj-clic sur un piston indépendant
permet de l'orienter vers la face cliquée. Un carré ambigu dans une surface
plus grande est refusé plutôt que de sélectionner des composants au hasard.
Un signal reçu par n'importe lequel des neuf composants fait avancer la tête
commune jusqu'à trois blocs. La coupure la fait rentrer ; seule la variante
gluante ramène la charge. Trois pas animés utilisent les collisions natives.
Une charge en chunk absent suspend aussi le retour gluant, sans charger le
chunk et sans l'abandonner comme si elle était un obstacle indéplaçable.
## Apparence
Les cinq PNG fournis sont conservés octet pour octet. Les faces 48 × 48 sont
réparties sur les neuf composants ; la bande de côté 48 × 16 fait le tour
du socle. **Les quatre pixels avant partent avec la tête** : le corps ouvert
ne conserve que douze pixels, avec la texture intérieure reculée et une
seule tige centrale qui comble l'intervalle. Les UV du côté sont découpés,
non étirés. Modèles, collisions et retour animé suivent cette géométrie dans
les six orientations, pour les deux variantes.
La synchronisation utilise les états et données des moving pistons natifs.
Le client initialise la BlockEntity des seuls paquets marqués par cette
machine avant leur chargement natif : un simple paquet d'état de bloc ne
crée pas la BlockEntity d'un moving piston.
## Vérifications
Parcours natif dans un monde plat neuf, graine 87, serveur intégré : douze
combinaisons direction/variante, assemblage réel à la clé, charge de 108,
refus à 109, obstacle au deuxième pas puis reprise, coffre immobile avec son
stock, boîte de Shulker cassée avec ses 17 diamants conservés, liaison slime
au-delà de la face, séparation slime/miel, retour gluant, transport vertical
d'une entité, coupure pendant l'animation, sérialisation des mouvements,
déchargement puis retour dans les chunks et démontage après casse.
Le test vérifie aussi la réception côté client des têtes en mouvement et du
retour final, les collisions de 12/4 pixels dans les six directions, le refus
hors hauteur et la suspension sur un chunk absent sans le charger.
Journal : `build/piston087-client.log` (succès, 1 min 20 s).
Captures inspectées : `build/piston087-screenshots/`, notamment
`0015_piston087-normal-face-open.png` et `0016_piston087-sticky-face-closed.png`.
Les essais à deux clients LAN distincts et avec un moteur de shaders tiers
restent à réaliser. Aucun serveur dédié démarré et aucune EULA acceptée.
`check build assemblePack assembleTestPack` a réussi en 2 min 29 s
(124 tâches, 104 exécutées), avec `:sanctuary:runGameTest` exclu pour respecter
le choix de tests client uniquement. Journal : `build/piston087-check.log`.
Le reçu `build/piston087-artifact.json` vérifie les 55 nouveaux assets, les
13 nouvelles classes, l'identité des cinq PNG, la conservation des assets
précédents, les sources embarquées et les archives. Les autres classes
existantes restent identiques, à l'exception du raccord dans `Multiblocks`.
Archives locales : `build/Sanctuary-beta.087.mrpack` et
`build/Sanctuary-Test-beta.087.mrpack`. Les archives beta.086 sont inchangées.
Aucun déploiement dans Prism ni sur le canal publié ; aucun monde personnel
modifié. Le pack de ressources intégré conserve sa version beta.070.
+87
View File
@@ -0,0 +1,87 @@
# beta.082 — nouvelles météos dans les commandes opérateur
Branche `codex/weather-commands-beta082`, Minecraft 26.3-pre-2, textures beta.070.
Les trois profils Sanctuary rejoignent la commande native :
- `/weather overcast` : ciel gris sans pluie.
- `/weather fog` : brouillard.
- `/weather showers` : averses alternant avec des périodes sèches.
Les six profils sont également disponibles sous le préfixe Sanctuary :
```text
/sanctuary weather clear
/sanctuary weather overcast
/sanctuary weather fog
/sanctuary weather showers
/sanctuary weather rain
/sanctuary weather thunder
```
Ces choix nécessitent les droits opérateur et, en solo, Allow Commands.
Ils sont masqués dans larbre transmis aux clients sans ces droits ; le
serveur refuse aussi une commande envoyée directement sans autorisation.
Le statut `/sanctuary weather` reste consultable normalement.
La confirmation de sélection est traduite FR/EN.
## Durée et sauvegarde
Le choix porte sur la **journée civile courante**, dans le fuseau du monde.
Il active le mode météo Real Time si la météo était en Vanilla, sans changer
le mode du soleil. Il naccepte pas de durée en ticks. Les averses conservent
leurs passages de pluie et leurs pauses : les choisir ne force pas un départ
immédiat de la pluie pendant un intervalle sec.
Le choix persiste après déconnexion, rechargement ou redémarrage. À la
prochaine date civile, le tirage quotidien existant reprend. Rechoisir le
même profil le même jour ne réécrit pas le journal. La sélection utilise
exactement le schéma 1 existant (`schema`, `realtime`, `date`, `weather`) ;
aucune migration de sauvegarde, dimension ou génération nest introduite.
L’écriture atomique précède la modification météo et sa synchronisation.
Un conflit d’écriture refuse la modification sans écraser le fichier externe.
`/sanctuary weather vanilla` rend la main au cycle Minecraft ; les commandes
natives `/weather clear`, `/weather rain` et `/weather thunder`, avec leurs
durées habituelles, y fonctionnent comme avant. En mode Real Time, elles
conservent leur message de redirection vers Vanilla ; les variantes sous
`/sanctuary weather` sélectionnent les profils quotidiens correspondants.
Les nouvelles commandes refusent la sélection si la météo Sanctuary est
globalement désactivée ou indisponible dans ce monde.
## Vérifications
`Weather082ClientChecks` réussit en **49 secondes** : commandes envoyées
par la connexion réelle du joueur, six profils, trois alias natifs,
autocomplétion masquée puis réactivée avec Allow Commands, refus des commandes
sans droits, état natif de pluie/orage et brouillard appliqué au rendu.
Le même scénario vérifie la sélection depuis Vanilla, lindépendance du
soleil, la conservation au rechargement, labsence d’écriture pour une
sélection identique, le passage à la date civile suivante, le retour aux
commandes natives, la désactivation par configuration, le refus dun conflit
de sauvegarde avant mutation météo et les confirmations FR/EN.
Journal : `build/weather082-client.log`.
Le contrôle existant `Weather057Smoke` est étendu aux six choix persistants,
à leur relecture, au maintien pour la même date et au tirage du lendemain.
`Weather057Smoke` passe **222 084 assertions**. La commande
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 36 s**, 124 tâches (104 exécutées, 20 à jour).
Journal : `build/weather082-check.log`.
Les 1 700 classes du JAR correspondent aux fichiers compilés ; les seules
classes différentes de beta.081 sont le service météo, son état et le journal.
Un libellé FR/EN ajouté ; textures, données et identifiants réseau inchangés.
Sources et packs vérifiés, même JAR embarqué, archives beta.081 préservées.
Reçu : `build/weather082-artifact.json`.
Les essais utilisent uniquement des mondes jetables et le serveur intégré ;
aucun serveur dédié, EULA supplémentaire, monde personnel ou déploiement.
## Archives
- [Sanctuary-beta.082.mrpack](../build/Sanctuary-beta.082.mrpack).
SHA-256 : `a1af14b31045c8e108a0f525098388aa4f3abc78cdb7f5141eb8cb6b9216e770`.
- [Sanctuary-Test-beta.082.mrpack](../build/Sanctuary-Test-beta.082.mrpack).
SHA-256 : `770952cf5c67af5bd135e4cd4ac093275407c3bd190396c0468bc36d2ca8212d`.
+7 -5
View File
@@ -2,13 +2,15 @@ org.gradle.jvmargs=-Xmx2G
org.gradle.parallel=true
org.gradle.configuration-cache=false
# Verified 2026-09-08. 26.3 is still a pre-release; do not silently move worlds.
minecraft_version=26.3-pre-2
# Verified 2026-09-16 against Mojang/Fabric metadata; personal worlds are not migrated.
minecraft_version=26.3
loader_version=0.19.5
loom_version=1.17.20
fabric_api_version=0.160.0+26.3
fabric_api_version=0.160.5+26.3
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
mod_version=beta.061
pack_version=beta.061
mod_version=beta.092
pack_version=beta.092
resource_pack_version=beta.090
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+8 -4
View File
@@ -1,9 +1,9 @@
# Just Enough Items — Sanctuary preview
# Just Enough Items — portage Sanctuary
Source officielle : https://github.com/mezz/JustEnoughItems/tree/26.2
Commit : `aae2dfcfb82e6b5bce787ba72bb2eda7339b274f`.
Version source 30.32.0 ; fork `30.32.0-sanctuary.2` pour **Minecraft 26.3-pre-2**
(Fabric normalise ce nom en `26.3-pre.2`), Loader 0.19.5, API 0.160.0+26.3, Java 25.
Version source 30.32.0 ; fork `30.32.0-sanctuary.3` pour **Minecraft 26.3**
(finale), Loader 0.19.5, API 0.160.5+26.3, Java 25.
Licence MIT, copyright 20142015 mezz. `LICENSE.txt` est inclus sans modification.
Le fork adapte les entrées SDL, les pipelines RenderPearl, les recettes devenues
@@ -37,9 +37,13 @@ Le mod JEI est imbriqué dans le JAR Sanctuary avec deux dépendances :
Amecs 26.2 sert uniquement à compiler le point dintégration optionnel historique ;
il nest ni embarqué ni annoncé compatible 26.3. Le client vérifié utilise les touches
natives sans Amecs. Les fichiers de licences des dépendances sont ajoutés au JAR JEI.
Ce fork expérimental ne revendique pas une validation upstream de cette préversion.
Ce fork expérimental ne revendique pas une validation upstream de ce portage.
La révision Sanctuary.2 retire le bouton de configuration du catalogue lorsque
Sanctuary le demande (dessin, infobulle et interactions), et adapte les libellés
FR/EN des favoris, de lhistorique et des raccourcis. Les attributions, identifiants
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é.
+6 -6
View File
@@ -1,7 +1,7 @@
plugins { id 'net.fabricmc.fabric-loom' }
version='30.32.0-sanctuary.2'
version=rootProject.jei_version
group='mezz.jei'
base { archivesName='jei-sanctuary-26.3-pre-2' }
base { archivesName="jei-sanctuary-${rootProject.minecraft_version}" }
repositories { mavenCentral(); maven { url='https://maven.siphalor.de/' }; maven { url='https://maven.blamejared.com/' } }
providers.exec { workingDir rootProject.projectDir; commandLine 'python3', 'scripts/prepare_jei.py' }.result.get()
def upstream=rootProject.file('build/jei-fork')
@@ -9,9 +9,9 @@ sourceSets.main.java.setSrcDirs(['Common/src/main/java','Common/src/api/java','F
sourceSets.main.resources.setSrcDirs(['Common/src/main/resources','Fabric/src/main/resources','Library/src/main/resources','Gui/src/main/resources'].collect{new File(upstream,it)})
loom { accessWidenerPath=new File(upstream,'Fabric/src/main/resources/jei.accesswidener') }
dependencies {
minecraft 'com.mojang:minecraft:26.3-pre-2'
implementation 'net.fabricmc:fabric-loader:0.19.5'
implementation 'net.fabricmc.fabric-api:fabric-api:0.160.0+26.3'
minecraft "com.mojang:minecraft:${rootProject.minecraft_version}"
implementation "net.fabricmc:fabric-loader:${rootProject.loader_version}"
implementation "net.fabricmc.fabric-api:fabric-api:${rootProject.fabric_api_version}"
implementation 'org.jetbrains:annotations:26.0.2'
implementation 'org.jspecify:jspecify:1.0.0'
implementation 'net.mezzdev:baked-substring-index:0.1.0'
@@ -23,7 +23,7 @@ dependencies {
java { toolchain { languageVersion=JavaLanguageVersion.of(25) } }
tasks.withType(JavaCompile).configureEach { options.encoding='UTF-8'; options.compilerArgs+=['-Xmaxerrs','300'] }
processResources {
def values=[modId:'jei', version:project.version, modName:'Just Enough Items — Sanctuary preview', modDescription:'Experimental source port for Sanctuary, Minecraft 26.3-pre-2.', modAuthor:'mezz', curseHomepageUrl:'https://www.curseforge.com/minecraft/mc-mods/jei', githubUrl:'https://github.com/mezz/JustEnoughItems', fabricLoaderVersionRange:'>=0.19.5', fabricApiVersionRange:'>=0.160.0+26.3', modJavaVersion:'25', amecsVersionFabric:'1.0.2']
def values=[modId:'jei', version:project.version, modName:'Just Enough Items — Sanctuary preview', modDescription:"Source port for Sanctuary, Minecraft ${rootProject.minecraft_version}.", minecraftVersion:rootProject.minecraft_version, modAuthor:'mezz', curseHomepageUrl:'https://www.curseforge.com/minecraft/mc-mods/jei', githubUrl:'https://github.com/mezz/JustEnoughItems', fabricLoaderVersionRange:">=${rootProject.loader_version}", fabricApiVersionRange:">=${rootProject.fabric_api_version}", modJavaVersion:'25', amecsVersionFabric:'1.0.2']
inputs.properties(values)
filesMatching('fabric.mod.json') { expand(values) }
duplicatesStrategy=DuplicatesStrategy.EXCLUDE
+1 -1
View File
@@ -907,7 +907,7 @@
"accessWidener" : "jei.accesswidener",
"depends": {
+ "minecraft": "26.3-pre.2",
+ "minecraft": "${minecraftVersion}",
"fabricloader": "${fabricLoaderVersionRange}",
"fabric-api": "${fabricApiVersionRange}",
"java": ">=${modJavaVersion}"
+87 -3
View File
@@ -95,7 +95,7 @@ tasks.named('runGameTest') {
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 = [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 = [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 introClient = providers.gradleProperty('sanctuaryIntroClientTests').map { it.toBoolean() }.getOrElse(false)
@@ -114,6 +114,85 @@ tasks.named('processGametestResources') {
if (skillsClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Skills025ClientChecks') }
def fogClient = providers.gradleProperty('sanctuaryFogClientTests').map { it.toBoolean() }.getOrElse(false)
def sky056Client = providers.gradleProperty('sanctuarySky056ClientTests').map { it.toBoolean() }.getOrElse(false)
def cloud077Client = providers.gradleProperty('sanctuaryCloud077ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryCloud077ClientTests', cloud077Client)
if (cloud077Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Cloud077ClientChecks') }
def navigation076Client = providers.gradleProperty('sanctuaryNavigation076ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryNavigation076ClientTests', navigation076Client)
if (navigation076Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Navigation076ClientChecks') }
def welcome083Client = providers.gradleProperty('sanctuaryWelcome083ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryWelcome083ClientTests', welcome083Client)
if (welcome083Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Welcome083ClientChecks') }
def piston087Client = providers.gradleProperty('sanctuaryPiston087ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPiston087ClientTests', piston087Client)
if (piston087Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Piston087ClientChecks') }
def fut086Client = providers.gradleProperty('sanctuaryFut086ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryFut086ClientTests', fut086Client)
if (fut086Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Fut086ClientChecks') }
def fourneau085Client = providers.gradleProperty('sanctuaryFourneau085ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryFourneau085ClientTests', fourneau085Client)
if (fourneau085Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Fourneau085ClientChecks') }
def pick092Client = providers.gradleProperty('sanctuaryPick092ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPick092ClientTests', pick092Client)
if (pick092Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Pick092ClientChecks') }
def boatPassengers091Client = providers.gradleProperty('sanctuaryBoatPassengers091ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryBoatPassengers091ClientTests', boatPassengers091Client)
if (boatPassengers091Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.BoatPassengers091ClientChecks') }
def multiblocks084Client = providers.gradleProperty('sanctuaryMultiblocks084ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryMultiblocks084ClientTests', multiblocks084Client)
if (multiblocks084Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Multiblocks084ClientChecks') }
def menus083Client = providers.gradleProperty('sanctuaryMenus083ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryMenus083ClientTests', menus083Client)
if (menus083Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Menus083ClientChecks') }
def weather082Client = providers.gradleProperty('sanctuaryWeather082ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryWeather082ClientTests', weather082Client)
if (weather082Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Weather082ClientChecks') }
def balance080Client = providers.gradleProperty('sanctuaryBalance080ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryBalance080ClientTests', balance080Client)
if (balance080Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Balance080ClientChecks') }
def solo079Client = providers.gradleProperty('sanctuarySolo079ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySolo079ClientTests', solo079Client)
if (solo079Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Solo079ClientChecks') }
def autonomy074Client = providers.gradleProperty('sanctuaryAutonomy074ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryAutonomy074ClientTests', autonomy074Client)
if (autonomy074Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Autonomy074ClientChecks') }
def arena073Client = providers.gradleProperty('sanctuaryArena073ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('arena073Client', arena073Client)
if (arena073Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Arena073ClientChecks') }
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 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') }
def resourcePack070Client = providers.gradleProperty('sanctuaryResourcePack070ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('resourcePack070Client', resourcePack070Client)
if (resourcePack070Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.ResourcePack070ClientChecks') }
def loading068Client = providers.gradleProperty('sanctuaryLoading068ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('loading068Client', loading068Client)
if (loading068Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Loading068ClientChecks') }
def familiar067Client = providers.gradleProperty('sanctuaryFamiliar067ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('familiar067Client', familiar067Client)
if (familiar067Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Familiar067ClientChecks') }
def cosmetic066Client = providers.gradleProperty('sanctuaryCosmetic066ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('cosmetic066Client', cosmetic066Client)
if (cosmetic066Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Cosmetic066ClientChecks') }
def boat065Client = providers.gradleProperty('sanctuaryBoat065ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('boat065Client', boat065Client)
if (boat065Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Boat065ClientChecks') }
def loading064Client = providers.gradleProperty('sanctuaryLoading064ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('loading064Client', loading064Client)
if (loading064Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Loading064ClientChecks') }
def gameplay063Client = providers.gradleProperty('sanctuaryGameplay063ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('gameplay063Client', gameplay063Client)
if (gameplay063Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Gameplay063ClientChecks') }
def loading063Client = providers.gradleProperty('sanctuaryLoading063ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('loading063Client', loading063Client)
inputs.property('loading063Entry', 'fr.koka.sanctuary.client.Loading063ClientAudit')
if (loading063Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Loading063ClientAudit') }
def sunlight062Client = providers.gradleProperty('sanctuarySunlight062ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sunlight062Client', sunlight062Client)
if (sunlight062Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Sunlight062ClientChecks') }
def arrivalMount061Client = providers.gradleProperty('sanctuaryArrivalMount061ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('arrivalMount061Client', arrivalMount061Client)
if (arrivalMount061Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.ArrivalMount061ClientChecks') }
@@ -311,10 +390,15 @@ tasks.withType(JavaCompile).configureEach {
}
processResources {
// The launcher uses 26.3-pre-2; Fabric's runtime normalizes it to 26.3-pre.2.
from(rootProject.file('ressources-pack/sanctuary')) {
include 'assets/**', 'pack.mcmeta', 'pack.png'
exclude '**/.DS_Store'
into 'resourcepacks/textures'
}
// 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]
loader_version: rootProject.loader_version, fabric_api_version: rootProject.fabric_api_version, jei_version: rootProject.jei_version]
inputs.properties(values)
filesMatching('fabric.mod.json') { expand(values) }
}
@@ -0,0 +1,139 @@
package fr.koka.sanctuary.client;
import com.mojang.authlib.GameProfile;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.arena.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.Connection;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.network.protocol.PacketFlow;
import net.minecraft.network.protocol.game.*;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.*;
import net.minecraft.server.network.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
/** Real integrated server, synthetic network players, native combat, escrow and FR/EN UI. */
public final class Arena073ClientChecks implements FabricClientGameTest {
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
private final List<ServerPlayer> remotes=new ArrayList<>();
private Arenas.Match current;
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){Starter039TestSupport.selectFirst(screen);screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);}});
ServerTickEvents.END_SERVER_TICK.register(s->{for(var p:List.copyOf(remotes))if(p.level().getServer()==s&&!p.isRemoved()){p.tickCount++;CompanionService.tick(p);FamiliarService.tick(p);FamiliarBattle.tick(p);}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Arena073 "+UUID.randomUUID());s.setSeed("73");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=host(s);p.teleportTo(.5,1,.5);p.getInventory().clearContent();p.getAbilities().invulnerable=false;s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set night");p.level().getGameRules().set(net.minecraft.world.level.gamerules.GameRules.PVP,false,s);
for(int x=-16;x<=16;x++)for(int z=-16;z<=16;z++)p.level().setBlockAndUpdate(new net.minecraft.core.BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
for(int i=0;i<5;i++)remotes.add(remote(p,"Arena073_"+i,i+2));
for(var e:all(p)){fresh(e);e.getInventory().setItem(0,new ItemStack(Items.EMERALD,10));}
check(Arenas.create(p,new Arenas.Rules(Arenas.Mode.FAMILIARS,false,true,24,180),0,2).equals("created"),"Create familiar arena");current=Arenas.match(p);
check(Arenas.join(current,remotes.get(0)).equals("joined")&&Arenas.join(current,remotes.get(1)).equals("joined"),"Third participant accepted");
check(Arenas.bet(current,p,p.getUUID(),1).equals("participant_bet"),"Participant cannot speculate");
check(Arenas.bet(current,remotes.get(2),p.getUUID(),4).equals("bet_placed"),"First spectator stake");
check(Arenas.bet(current,remotes.get(3),p.getUUID(),2).equals("bet_placed"),"Second spectator stake");
check(Arenas.bet(current,remotes.get(4),remotes.get(0).getUUID(),3).equals("bet_placed"),"Losing-side spectator stake");
check(Arenas.join(current,remotes.get(2)).equals("bettor"),"Spectator cannot join after betting");
check(Arenas.start(current,p).equals("not_ready"),"All entrants must confirm immutable rules");
check(Arenas.ledger(s).pot(current.id,true)==6&&Arenas.ledger(s).pot(current.id,false)==9,"Separate entry and spectator pots");
for(var e:current.entrants.values())Arenas.ready(current,e.player);
});
c.runOnClient(m->ArenaClient.open(null));c.waitFor(m->m.gui.screen() instanceof ArenaScreen,100);
for(var locale:List.of("fr_fr","en_us")){c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new ArenaScreen(null));});c.waitTicks(5);c.takeScreenshot("arena073-board-"+locale);}
server.runOnServer(s->ArenaNetwork.request(host(s),new ArenaNetwork.Request("open",current.id.toString(),"",0,-1,0,0,"")));c.waitTicks(5);c.takeScreenshot("arena073-registration");
server.runOnServer(s->{var p=host(s);check(Arenas.start(current,p).equals("started"),"Unanimous start");check(Arenas.bet(current,remotes.get(2),p.getUUID(),1).equals("closed"),"Bets close before countdown");current.until=FamiliarBattle.now();Arenas.tick(s);
var pet=FamiliarService.active(p);var rival=FamiliarService.active(remotes.get(0));check(FamiliarBattle.enemy(p,rival)&&!FamiliarBattle.enemy(p,remotes.get(0)),"Familiar-only consent boundary");check(!rival.hurtServer(p.level(),p.damageSources().playerAttack(p),10),"Owner cannot attack a rival pet");check(!p.hurtServer(p.level(),p.damageSources().playerAttack(remotes.get(2)),10),"Bystander cannot attack organizer");
pet.setPos(.5,1,2);rival.setPos(1.5,1,2);FamiliarBattle.engage(p,rival,false);
});c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(65);
server.runOnServer(s->{var p=host(s);check(current.phase==Arenas.Phase.FIGHT,"Three-way arena running");check(current.entrants.values().stream().anyMatch(e->FamiliarBattle.bond(e.player).health()<FamiliarBattle.profile(e.player).health()),"Native autonomous attacks cause wounds");
for(int i=0;i<2;i++){var rival=FamiliarService.active(remotes.get(i));if(rival!=null){var pet=FamiliarService.active(p);pet.setPos(.5,1,2);rival.setPos(1.5,1,2);var cast=new FamiliarTechniques.Cast(pet,FamiliarBattle.profile(p).initiative(),false,rival,rival.position());for(int hit=0;hit<50&&current.phase==Arenas.Phase.FIGHT&&!current.entrants.get(remotes.get(i).getUUID()).out;hit++)FamiliarTechniques.hit(cast,rival,4);}}
check(current.phase==Arenas.Phase.FINISHED&&p.getUUID().equals(current.winner),"Last living familiar wins");for(var e:all(p))Arenas.ledger(s).claim(e);
check(count(p)==14&&count(remotes.get(0))==8&&count(remotes.get(1))==8,"Winner receives exact entry pool");check(count(remotes.get(2))==12&&count(remotes.get(3))==11&&count(remotes.get(4))==7,"Spectator pari-mutuel payout conserves all nine items");
Arenas.ledger(s).claim(p);check(count(p)==14,"No duplicate payout");System.out.println("ARENA073_FAMILIARS_PASS: three entrants, native AI, external interference, registration and spectator pools");
});c.waitTicks(110);
server.runOnServer(s->{var p=host(s);var q=remotes.get(0);var r=remotes.get(1);for(var e:all(p))fresh(e);
check(Arenas.create(q,new Arenas.Rules(Arenas.Mode.PLAYERS,false,false,24,180),-1,0).equals("created"),"Players-only arena");current=Arenas.match(q);check(Arenas.join(current,p).equals("joined")&&Arenas.join(current,r).equals("joined"),"Three human entrants");for(var e:current.entrants.values())Arenas.ready(current,e.player);Arenas.start(current,q);current.until=FamiliarBattle.now();Arenas.tick(s);
check(p.canHarmPlayer(q),"Consensual arena bypasses global PvP=false");check(!p.canHarmPlayer(remotes.get(2)),"Global PvP remains off for spectators");check(!FamiliarBattle.enemy(p,FamiliarService.active(q)),"Familiars excluded from human-only fight");
check(!p.gameMode.destroyBlock(new net.minecraft.core.BlockPos(0,0,0)),"Arena blocks native/grouped mining");
check(p.gameMode.useItem(p,p.level(),new ItemStack(Items.COOKED_BEEF),net.minecraft.world.InteractionHand.MAIN_HAND)==net.minecraft.world.InteractionResult.FAIL,"Food disabled by arena rules");
int items=count(q);q.setInvulnerableTime(0);check(q.hurtServer(p.level(),p.damageSources().playerAttack(p),100),"Native player damage accepted");check(q.getHealth()<=0&&!q.isAlive()&&count(q)==0&&current.entrants.get(q.getUUID()).out,"Native lethal hit kills and moves inventory to a grave");
check(!q.hurtServer(p.level(),p.damageSources().playerAttack(p),100),"Eliminated players cannot be hit again");r.setInvulnerableTime(0);r.hurtServer(p.level(),p.damageSources().playerAttack(p),100);check(current.phase==Arenas.Phase.FINISHED&&p.getUUID().equals(current.winner),"Player arena resolves by actual death");System.out.println("ARENA073_PLAYERS_PASS: native damage, real death, PvP consent and pet exclusion");
});c.waitTicks(110);
server.runOnServer(s->{var p=host(s);var q=remotes.get(0);var r=remotes.get(1);for(int i=0;i<2;i++){var old=remotes.get(i);var petOld=FamiliarService.active(old);if(petOld!=null)petOld.discard();old.discard();remotes.set(i,remote(p,"Rejoined073_"+i,3+i));}q=remotes.get(0);r=remotes.get(1);for(var e:all(p))fresh(e);
check(Arenas.create(r,new Arenas.Rules(Arenas.Mode.MIXED,true,true,24,180),-1,0).equals("created"),"Mixed duel creation");current=Arenas.match(r);check(Arenas.join(current,p).equals("joined"),"Mixed duel second entrant");check(Arenas.join(current,q).equals("unavailable"),"Only duels cap entry at two");for(var e:current.entrants.values())Arenas.ready(current,e.player);Arenas.start(current,r);current.until=FamiliarBattle.now();Arenas.tick(s);
var rival=FamiliarService.active(r);var pet=FamiliarService.active(p);pet.setPos(.5,1,2);rival.setPos(1.5,1,2);check(FamiliarBattle.enemy(p,r)&&FamiliarBattle.enemy(p,rival),"Mixed fight allows both target kinds");check(rival.hurtServer(p.level(),p.damageSources().playerAttack(p),1000),"Players can KO rival pets in mixed fight");check(!current.entrants.get(r.getUUID()).out&&current.phase==Arenas.Phase.FIGHT,"Pet KO leaves its human in mixed fight");
Arenas.eliminate(r,"forfeit");check(p.getUUID().equals(current.winner),"Mixed forfeit settles winner");
escrow(s,p,q);System.out.println("ARENA073_MIXED_ESCROW_PASS: two-player duel, mixed combat, refund, restart and item components");
});c.waitTicks(110);
server.runOnServer(s->{var p=host(s);var q=remotes.get(0);fresh(p);fresh(q);p.teleportTo(.5,1,.5);q.setPos(.5,1,4);check(FamiliarDuels.invite(p,q).equals("duel.sent"),"Legacy duel stays available");var duel=FamiliarDuels.match(p);var board=Arenas.get(s,duel.id);check(board!=null&&board.legacy&&board.entrants.size()==2,"Legacy duel publicly listed");check(Arenas.bet(board,remotes.get(2),p.getUUID(),1).equals("bet_placed"),"Spectators can bet on legacy duels");int before=count(remotes.get(2));FamiliarDuels.finish(duel,-1,"cancelled");Arenas.ledger(s).claim(remotes.get(2));check(count(remotes.get(2))==before+1,"Cancelled legacy duel refunds spectators");
fresh(p);check(Arenas.create(p,new Arenas.Rules(Arenas.Mode.PLAYERS,false,true,96,600),-1,0).equals("created"),"Large roster fixture");current=Arenas.match(p);
for(int i=0;i<24;i++){var other=remote(p,"Roster073_"+i,2+i%5);remotes.add(other);check(Arenas.join(current,other).equals("joined"),"No artificial arena player cap at entrant "+(i+2));}
check(current.entrants.size()==25,"Arena roster exceeds legacy and page limits");var doomed=remotes.getLast();int beforeSize=current.entrants.size();doomed.setInvulnerableTime(0);doomed.hurtServer(p.level(),doomed.damageSources().generic(),100);check(!doomed.isAlive()&&current.phase==Arenas.Phase.OPEN&&current.entrants.size()==beforeSize-1,"Death during registration withdraws only that entrant");var replacement=remote(p,"Roster073_new",6);remotes.add(replacement);check(Arenas.join(current,replacement).equals("joined"),"Free place stays open after registration death");ArenaNetwork.request(p,new ArenaNetwork.Request("open",current.id.toString(),"",0,-1,0,1,""));
});c.waitTicks(8);c.runOnClient(m->{check(ArenaClient.data.get("total").getAsInt()==25&&ArenaClient.data.getAsJsonObject("match").getAsJsonArray("entrants").size()==12,"Paginated bounded roster payload");});c.takeScreenshot("arena073-roster-page2");
server.runOnServer(s->{Arenas.finish(current,null,"cancelled");for(var q:remotes){FamiliarDuels.disconnect(q);FamiliarBattle.release(q);var pet=FamiliarService.active(q);if(pet!=null)pet.discard();q.discard();}remotes.clear();});
c.waitTicks(210);server.runOnServer(s->fresh(host(s)));
c.runOnClient(m->ArenaClient.open(null));c.waitTicks(8);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.arena.create").onPress(enter()));c.waitTicks(4);c.takeScreenshot("arena073-create");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.arena.create_confirm").onPress(enter()));c.waitTicks(8);
server.runOnServer(s->{check(Arenas.match(host(s))!=null,"Real GUI creates event through network");Arenas.finish(Arenas.match(host(s)),null,"cancelled");});c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(110);
fr.koka.sanctuary.gametest.Duel054Checks.run(c,server);
}finally{intro.set(false);remotes.clear();}
System.out.println("ARENA073_PASS");
}
private void escrow(MinecraftServer s,ServerPlayer p,ServerPlayer q){
var ledger=Arenas.ledger(s);var id=UUID.randomUUID();p.getInventory().clearContent();q.getInventory().clearContent();p.getInventory().setItem(0,new ItemStack(Items.EMERALD,7));q.getInventory().setItem(0,new ItemStack(Items.EMERALD,5));
check(ledger.deposit(id,p,p.getUUID(),true,new ItemStack(Items.EMERALD),7)&&ledger.deposit(id,q,q.getUUID(),true,new ItemStack(Items.EMERALD),5),"Durable entry deposits");
for(int slot:CompanionInventory.slots(p,true))p.getInventory().setItem(slot,new ItemStack(Items.STONE,64));ledger.finish(id,p.getUUID());ledger.claim(p);check(ledger.pending(p),"Full inventory retains winnings");
p.getInventory().clearContent();ledger.claim(p);check(count(p)==12&&!ledger.pending(p),"Deferred payout exact");
var weights=new TreeMap<UUID,Long>();weights.put(new UUID(0,1),1L);weights.put(new UUID(0,2),2L);check(ArenaLedger.split(10,weights).values().stream().mapToLong(Long::longValue).sum()==10,"Integer remainder conserved");
var interrupted=UUID.randomUUID();check(ledger.deposit(interrupted,p,p.getUUID(),false,new ItemStack(Items.EMERALD),3),"Restart fixture deposited");
try{var recovered=new ArenaLedger(s);recovered.claim(p);check(count(p)==12,"Restart refunds live event");recovered.claim(p);check(count(p)==12,"Restart refund is idempotent");
var withdrawal=UUID.randomUUID();p.getInventory().setItem(0,new ItemStack(Items.EMERALD,12));q.getInventory().setItem(0,new ItemStack(Items.EMERALD,5));
check(recovered.deposit(withdrawal,q,q.getUUID(),true,new ItemStack(Items.EMERALD),2)&&recovered.deposit(withdrawal,p,q.getUUID(),false,new ItemStack(Items.EMERALD),3),"Withdrawal fixture");recovered.leave(withdrawal,q.getUUID());recovered.claim(p);recovered.claim(q);check(count(p)==12&&count(q)==5,"Withdrawal refunds both entry and bets on withdrawn entrant");
// Fresh journal replaces the test-owned ledger only after all old entries are settled.
var field=Arenas.class.getDeclaredField("LEDGERS");field.setAccessible(true);((Map<MinecraftServer,ArenaLedger>)field.get(null)).put(s,recovered);
var special=new ItemStack(Items.CHEST);special.set(DataComponents.CUSTOM_NAME,Component.literal("Arena archive"));special.set(DataComponents.CONTAINER,net.minecraft.world.item.component.ItemContainerContents.fromItems(List.of(new ItemStack(Items.DIAMOND,3))));p.getInventory().setItem(1,special.copy());var custom=UUID.randomUUID();check(recovered.deposit(custom,p,p.getUUID(),true,special,1),"Component-bearing item deposited");recovered.finish(custom,null);recovered.claim(p);check(CompanionInventory.slots(p,true).stream().anyMatch(slot->ItemStack.matches(special,p.getInventory().getItem(slot))),"Nested container components preserved");
var journal=s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("sanctuary/arena-stakes-v1.json");
var crash=UUID.randomUUID();check(recovered.deposit(crash,p,p.getUUID(),true,new ItemStack(Items.EMERALD),3),"Crash after native debit fixture");
var root=com.google.gson.JsonParser.parseString(java.nio.file.Files.readString(journal)).getAsJsonObject();var entry=root.getAsJsonObject("deposits").entrySet().iterator().next();var template=entry.getValue().getAsJsonObject().deepCopy();entry.getValue().getAsJsonObject().addProperty("state","pending");java.nio.file.Files.writeString(journal,root.toString());
recovered=new ArenaLedger(s);recovered.claim(p);check(count(p)==12,"Prepared debit with saved receipt refunds exactly once");
root=com.google.gson.JsonParser.parseString(java.nio.file.Files.readString(journal)).getAsJsonObject();template.addProperty("state","pending");root.getAsJsonObject("deposits").add(UUID.randomUUID().toString(),template);java.nio.file.Files.writeString(journal,root.toString());
recovered=new ArenaLedger(s);recovered.claim(p);check(count(p)==12,"Prepared debit without receipt never invents a refund");
var payment=UUID.randomUUID();check(recovered.deposit(payment,p,p.getUUID(),true,new ItemStack(Items.EMERALD),3),"Crash after payout fixture");recovered.finish(payment,p.getUUID());
root=com.google.gson.JsonParser.parseString(java.nio.file.Files.readString(journal)).getAsJsonObject();recovered.claim(p);var due=root.getAsJsonObject("payouts").entrySet().iterator().next().getValue().getAsJsonObject();due.addProperty("transfer",p.getAttachedOrElse(ArenaLedger.RECEIPT,""));due.addProperty("moving",3);java.nio.file.Files.writeString(journal,root.toString());
recovered=new ArenaLedger(s);recovered.claim(p);recovered.claim(p);check(count(p)==12,"Saved payout receipt prevents paying the same items again");
String valid=java.nio.file.Files.readString(journal);var invalid=com.google.gson.JsonParser.parseString(valid).getAsJsonObject();invalid.addProperty("schema",2);java.nio.file.Files.writeString(journal,invalid.toString());boolean refused=false;try{new ArenaLedger(s);}catch(Exception expected){refused=true;}check(refused&&java.nio.file.Files.readString(journal).equals(invalid.toString()),"Unknown journal schema rejected without overwrite");java.nio.file.Files.writeString(journal,valid);recovered=new ArenaLedger(s);((Map<MinecraftServer,ArenaLedger>)field.get(null)).put(s,recovered);
System.out.println("ARENA073_RECOVERY_PASS: before debit, after debit, after payout, unknown schema");
}catch(Exception e){throw new AssertionError(e);}
}
private static ServerPlayer remote(ServerPlayer p,String name,int z){var s=p.level().getServer();var profile=new GameProfile(UUID.randomUUID(),name);var other=new ServerPlayer(s,p.level(),profile,ClientInformation.createDefault());var connection=new Connection(PacketFlow.SERVERBOUND);new io.netty.channel.embedded.EmbeddedChannel(connection);other.connection=new ServerGamePacketListenerImpl(s,connection,other,CommonListenerCookie.createInitial(profile,false));other.setPos(.5,1,z);other.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(other,ProgressRecord.INITIAL);other.setHealth(6);other.connection.handleAcceptPlayerLoad(new ServerboundPlayerLoadedPacket());p.connection.send(ClientboundPlayerInfoUpdatePacket.createPlayerInitializing(List.of(other)));p.level().addNewPlayer(other);return other;}
private List<ServerPlayer> all(ServerPlayer p){var list=new ArrayList<>(remotes);list.addFirst(p);return list;}
private static void fresh(ServerPlayer p){p.setHealth(p.getMaxHealth());p.getAbilities().invulnerable=false;p.setInvulnerableTime(0);p.stopRiding();CarryService.detach(p);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);p.removeAttached(FamiliarBattle.COMBAT_LOCK);p.removeAttached(FamiliarBattle.LAST_FAMILIAR);var egg=new ItemStack(Items.WOLF_SPAWN_EGG);var profile=FamiliarBattle.world(p.level().getServer()).catalog.forEgg(egg);var id=UUID.randomUUID();FamiliarBattle.world(p.level().getServer()).store.put(FamiliarBond.create(id,profile,0,"test",p.getUUID()));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,FamiliarIdentity.assign(egg,id));CompanionService.tick(p);p.tickCount+=10-p.tickCount%10;FamiliarService.tick(p);check(FamiliarService.active(p)!=null,"Spawn test familiar");}
private static ServerPlayer host(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static int count(ServerPlayer p){return CompanionInventory.slots(p,true).stream().mapToInt(i->p.getInventory().getItem(i).is(Items.EMERALD)?p.getInventory().getItem(i).getCount():0).sum();}
private static java.util.stream.Stream<net.minecraft.client.gui.components.events.GuiEventListener> widgets(net.minecraft.client.gui.components.events.GuiEventListener root){return java.util.stream.Stream.concat(java.util.stream.Stream.of(root),root instanceof net.minecraft.client.gui.components.events.ContainerEventHandler c?c.children().stream().flatMap(Arena073ClientChecks::widgets):java.util.stream.Stream.empty());}
private static Button button(net.minecraft.client.gui.screens.Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
}
@@ -0,0 +1,138 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.companion.CompanionService;
import fr.koka.sanctuary.companion.CompanionWorld;
import net.minecraft.client.KeyMapping;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.CustomData;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.Vec3;
/** Exercises server decisions, native navigation/damage and actual short/hold keyboard gestures. */
public final class Autonomy074ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
var forbidden=new AtomicReference<UUID>();
CompanionWorld.ALLOW.register((p,pos,op)->forbidden.get()==null||p.level().getEntity(forbidden.get())==null||!p.level().getEntity(forbidden.get()).blockPosition().equals(pos));
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Autonomy074 "+UUID.randomUUID());s.setSeed("74");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);
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set night");
for(int x=-20;x<=20;x++)for(int z=-20;z<=20;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
});
for(var personality:FamiliarPersonality.values()){
var victim=new AtomicReference<Mob>();
server.runOnServer(s->{var p=player(s);p.teleportTo(.5,1,.5);p.setDeltaMovement(Vec3.ZERO);equip(p,Items.WOLF_SPAWN_EGG,personality,1000);FamiliarService.active(p).setPos(.5,1,1.5);victim.set(enemy(p,9.5,1,1.5));});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check((FamiliarBattle.target(p)!=null)==(personality==FamiliarPersonality.AGGRESSIVE),"12-block hunt: "+personality);FamiliarBattle.cancel(p);victim.get().setPos(4.5,1,1.5);});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check((FamiliarBattle.target(p)!=null)==(personality==FamiliarPersonality.AGGRESSIVE||personality==FamiliarPersonality.PROTECTIVE),"6-block guard: "+personality+" player="+p.position()+" pet="+FamiliarService.active(p).position()+" victim="+victim.get().position()+" target="+FamiliarBattle.target(p));
FamiliarBattle.cancel(p);FamiliarBattle.damageEvent(victim.get(),p.damageSources().playerAttack(p),1);
check((FamiliarBattle.target(p)!=null)==(personality==FamiliarPersonality.AGGRESSIVE),"Only aggressive joins unsolicited owner attack: "+personality);
FamiliarBattle.cancel(p);p.getAbilities().invulnerable=false;p.setInvulnerableTime(0);p.hurtServer(p.level(),p.damageSources().mobAttack(victim.get()),1);p.getAbilities().invulnerable=true;
check((FamiliarBattle.target(p)!=null)==(personality!=FamiliarPersonality.PACIFIST),"Native owner hurt: "+personality);
if(personality==FamiliarPersonality.PACIFIST){check(FamiliarBattle.state(p).evadeUntil>p.tickCount,"Pacifist retreats");FamiliarBattle.engage(p,victim.get(),false);}
});c.waitTicks(110);
server.runOnServer(s->{var p=player(s);check(victim.get().getHealth()<100,"Real autonomous navigation/damage: "+personality+" distance="+FamiliarService.active(p).distanceTo(victim.get()));victim.get().discard();FamiliarBattle.cancel(p);p.setHealth(p.getMaxHealth());
System.out.println("AUTONOMY074_POLICY_PASS "+personality);
});
}
// Protective support species can intercept, really strike, then come back to guard.
var first=new AtomicReference<Mob>();var next=new AtomicReference<Mob>();
server.runOnServer(s->{var p=player(s);p.teleportTo(.5,1,.5);p.setDeltaMovement(Vec3.ZERO);equip(p,Items.VILLAGER_SPAWN_EGG,FamiliarPersonality.PROTECTIVE,1000);FamiliarService.active(p).setPos(.5,1,1.5);p.getAbilities().invulnerable=false;first.set(enemy(p,4.5,1,1.5));});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==first.get(),"Guard first engages an idle intruder");next.set(enemy(p,5.5,1,-1.5));next.get().setTarget(p);});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==next.get(),"Guard prioritizes a monster targeting the owner: selected="+FamiliarBattle.target(p)+" threat="+next.get()+" threatTarget="+next.get().getTarget()+" p="+p.position());});c.waitTicks(100);
server.runOnServer(s->{var p=player(s);check(next.get().getHealth()<100,"Protective villager really defends without a shield");next.get().discard();});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==first.get(),"Reacquires next intruder after target dies");first.get().setPos(13.5,1,1.5);});c.waitTicks(70);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==null,"Guard gives up pursuit beyond ten blocks");check(FamiliarService.active(p).distanceToSqr(p)<25,"Guard returns to owner");first.get().discard();p.getAbilities().invulnerable=true;
System.out.println("AUTONOMY074_GUARD_PASS");
});
server.runOnServer(s->{var p=player(s);equip(p,Items.VILLAGER_SPAWN_EGG,FamiliarPersonality.PASSIVE,1000);
p.getAbilities().invulnerable=false;var bee=EntityTypes.BEE.create(p.level(),EntitySpawnReason.COMMAND);bee.setNoAi(true);bee.setPos(3.5,1,1.5);p.level().addFreshEntity(bee);
check(!FamiliarBattle.enemy(p,bee),"Peaceful bee is not an enemy");bee.setTarget(p);check(FamiliarBattle.enemy(p,bee),"An attacking neutral bee can be defended against");
FamiliarBattle.damageEvent(p,p.damageSources().mobAttack(bee),1);check(FamiliarBattle.target(p)==bee,"Passive companion defends against a neutral aggressor");bee.discard();p.getAbilities().invulnerable=true;FamiliarBattle.cancel(p);
equip(p,Items.VILLAGER_SPAWN_EGG,FamiliarPersonality.PACIFIST,1000);FamiliarBattle.state(p).lastRequest=-100;FamiliarBattle.command(p,"protect");first.set(enemy(p,4.5,1,1.5));
});c.waitTicks(90);
server.runOnServer(s->{var p=player(s);check(first.get().getHealth()<100,"Pacifist obeys explicit guard order and really strikes");next.set(enemy(p,8.5,1,1.5));FamiliarBattle.engage(p,next.get(),false);
FamiliarBattle.automatic(p,first.get(),FamiliarPersonality.Trigger.OWNER_HURT);check(FamiliarBattle.target(p)==next.get()&&FamiliarBattle.state(p).explicitTarget,"Explicit target has priority over another attacker");
first.get().discard();next.get().discard();FamiliarBattle.cancel(p);System.out.println("AUTONOMY074_NEUTRAL_AND_ORDERS_PASS");
});
// A pet's sight is independent from the owner's, but neither sees through walls.
server.runOnServer(s->{var p=player(s);equip(p,Items.WOLF_SPAWN_EGG,FamiliarPersonality.AGGRESSIVE,1000);var pet=FamiliarService.active(p);pet.setPos(.5,1,4.5);pet.setNoAi(true);first.set(enemy(p,8.5,1,4.5));
for(int y=1;y<=4;y++)for(int z=0;z<=2;z++)p.level().setBlockAndUpdate(new BlockPos(3,y,z),Blocks.STONE.defaultBlockState());
check(!p.hasLineOfSight(first.get())&&pet.hasLineOfSight(first.get()),"Different sightlines fixture");
});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==first.get(),"Own sight selects a target hidden from player");FamiliarBattle.cancel(p);for(int y=1;y<=4;y++)for(int z=-3;z<=9;z++)p.level().setBlockAndUpdate(new BlockPos(3,y,z),Blocks.STONE.defaultBlockState());});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==null,"No acquisition through a solid wall");FamiliarBattle.automatic(p,first.get(),FamiliarPersonality.Trigger.OWNER_HURT);
for(int x=7;x<=10;x++)for(int z=3;z<=6;z++)for(int y=1;y<=4;y++)if(x==7||x==10||z==3||z==6||y==4)p.level().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.STONE.defaultBlockState());
FamiliarService.active(p).setNoAi(false);});c.waitTicks(115);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==null,"Loses enclosed target instead of looping at wall: pet="+FamiliarService.active(p).position()+" victim="+first.get().position());check(first.get().getUUID().equals(FamiliarBattle.state(p).unreachable),"Unreachable target has bounded retry delay");first.get().discard();
for(int x=7;x<=10;x++)for(int z=3;z<=6;z++)for(int y=1;y<=4;y++)p.level().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());
for(int y=1;y<=4;y++)for(int z=-3;z<=9;z++)p.level().setBlockAndUpdate(new BlockPos(3,y,z),Blocks.AIR.defaultBlockState());
FamiliarBattle.cancel(p);first.set(enemy(p,4.5,1,1.5));forbidden.set(first.get().getUUID());
});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==null,"Permission veto prevents automatic acquisition");forbidden.set(null);FamiliarBattle.state(p).workMode=true;});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==null,"Work mode suppresses autonomy");FamiliarBattle.state(p).workMode=false;FamiliarBattle.state(p).lastRequest=-100;FamiliarBattle.command(p,"follow");});c.waitTicks(40);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==null,"Follow order has a five-second grace period");});c.waitTicks(85);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==first.get(),"Autonomy resumes after follow grace: p="+p.position()+" pet="+FamiliarService.active(p).position()+" tick="+p.tickCount+" after="+FamiliarBattle.state(p).automaticAfter+" target="+FamiliarBattle.target(p)+" victim="+first.get().position());first.get().discard();FamiliarBattle.cancel(p);System.out.println("AUTONOMY074_SIGHT_WORK_FOLLOW_PASS");});
// Server aims the short order; native F stays untouched, and long holds never release an attack.
server.runOnServer(s->{var p=player(s);equip(p,Items.WOLF_SPAWN_EGG,FamiliarPersonality.PACIFIST,1000);p.teleportTo(.5,1,.5);first.set(enemy(p,.5,1,7.5));});c.waitTicks(15);
c.getInput().lookAt(new BlockPos(0,2,7));c.waitTicks(5);c.getInput().pressKey(InputConstants.KEY_H);c.waitTicks(10);
c.runOnClient(m->check(m.gui.screen()==null,"Short H does not open a screen"));
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==first.get()&&FamiliarBattle.state(p).explicitTarget,"Short H attacks selected mob even with pacifist");});
c.getInput().lookAt(new BlockPos(0,20,-8));c.waitTicks(5);c.getInput().pressKey(InputConstants.KEY_H);c.waitTicks(10);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==null&&FamiliarBattle.state(p).order==FamiliarBattle.Order.FOLLOW,"Short H without a target returns to follow");});
c.getInput().lookAt(new BlockPos(0,2,7));c.waitTicks(5);c.getInput().holdKey(InputConstants.KEY_H);c.waitFor(m->m.gui.screen() instanceof FamiliarOrdersScreen,100);c.getInput().releaseKey(InputConstants.KEY_H);c.waitTicks(5);
c.takeScreenshot("autonomy074-orders");c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(10);
server.runOnServer(s->check(FamiliarBattle.target(player(s))==null,"Releasing long H does not fire a quick command"));
c.runOnClient(m->{KeyMapping.get("sanctuary.familiar.orders_key").setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_Y));KeyMapping.resetMapping();});
c.getInput().pressKey(InputConstants.KEY_Y);c.waitTicks(10);server.runOnServer(s->check(FamiliarBattle.target(player(s))==first.get(),"Remapped short Y orders work"));
c.getInput().holdKey(InputConstants.KEY_Y);c.waitFor(m->m.gui.screen() instanceof FamiliarOrdersScreen,100);c.getInput().releaseKey(InputConstants.KEY_Y);c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(5);
server.runOnServer(s->{var p=player(s);p.getInventory().setItem(0,new ItemStack(Items.DIAMOND));p.getInventory().setSelectedSlot(0);p.setItemInHand(net.minecraft.world.InteractionHand.OFF_HAND,new ItemStack(Items.GOLD_INGOT));p.inventoryMenu.broadcastChanges();});c.waitTicks(10);c.getInput().pressKey(InputConstants.KEY_F);c.waitTicks(10);
server.runOnServer(s->{var p=player(s);check(p.getMainHandItem().is(Items.GOLD_INGOT)&&p.getOffhandItem().is(Items.DIAMOND),"F remains vanilla swap hands");first.get().discard();FamiliarBattle.cancel(p);});
c.runOnClient(m->{var key=KeyMapping.get("sanctuary.familiar.orders_key");key.setKey(key.getDefaultKey());KeyMapping.resetMapping();});
for(String locale:List.of("fr_fr","en_us")){
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());FamiliarClient.open(null);});c.waitTicks(5);c.takeScreenshot("autonomy074-personality-"+locale);c.runOnClient(m->m.gui.setScreen(null));
}
System.out.println("AUTONOMY074_INPUT_PASS: native H tap/hold, remapped Y, F swap, FR/EN screens");
}finally{intro.set(false);forbidden.set(null);c.getInput().releaseKey(InputConstants.KEY_H);c.getInput().releaseKey(InputConstants.KEY_Y);}
System.out.println("AUTONOMY074_PASS");
}
private static void equip(ServerPlayer p,Item item,FamiliarPersonality personality,int size){
p.stopRiding();CarryService.detach(p);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);p.removeAttached(FamiliarBattle.COMBAT_LOCK);p.removeAttached(FamiliarBattle.LAST_FAMILIAR);
var egg=new ItemStack(item);var scale=new CompoundTag();scale.putInt("schema",1);scale.putInt("per_mille",size);CustomData.update(DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,scale));
var profile=FamiliarBattle.world(p.level().getServer()).catalog.forEgg(egg);UUID id;do{id=UUID.randomUUID();}while(FamiliarPersonality.of(id,profile.id())!=personality);
FamiliarBattle.world(p.level().getServer()).store.put(FamiliarBond.create(id,profile,0,"test",p.getUUID()));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,FamiliarIdentity.assign(egg,id));
CompanionService.tick(p);p.tickCount+=10-p.tickCount%10;FamiliarService.tick(p);check(FamiliarService.active(p)!=null,"Familiar spawned: "+item);p.inventoryMenu.broadcastChanges();
}
private static Mob enemy(ServerPlayer p,double x,double y,double z){var e=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);e.setNoAi(true);e.getAttribute(Attributes.MAX_HEALTH).setBaseValue(100);e.setHealth(100);e.setPos(x,y,z);p.level().addFreshEntity(e);return e;}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,131 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.building.*;
import fr.koka.sanctuary.mining.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.ai.attributes.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
/** Actual input, native prediction, play packets and authoritative blocks in a disposable solo world. */
public final class Balance080ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Balance080 "+UUID.randomUUID());s.setSeed("80");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.getInventory().clearContent();p.getAbilities().invulnerable=true;
for(int x=-4;x<=4;x++)for(int z=-2;z<=10;z++){p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.BEDROCK.defaultBlockState());for(int y=1;y<=6;y++)p.level().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());}
p.teleportTo(.5,1,.75);p.setOnGround(true);curve(p);
});
// The base skill places nearby, rejects a normal 4.25-block click, and consumes no stock on rejection.
setup(c,server,0,0,3,Items.COBBLESTONE,false);use(c);server.waitFor(s->player(s).level().getBlockState(pos(3).north()).is(Blocks.COBBLESTONE),100);
setup(c,server,0,0,5,Items.COBBLESTONE,false);use(c);c.waitTicks(12);
server.runOnServer(s->{var p=player(s);check(p.level().getBlockState(pos(5).north()).isAir()&&p.getMainHandItem().getCount()==64,"Rank zero rejects far placement without consuming stock");});
// Construction alone reaches 5.25 blocks; Mining stays at its own rank-zero reach.
setup(c,server,0,7,6,Items.COBBLESTONE,false);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult h&&h.getBlockPos().equals(pos(6))&&h.getType()==HitResult.Type.BLOCK,"Extended construction target picked"));
c.getInput().holdKey(o->o.keyAttack);c.waitTicks(25);c.getInput().releaseKey(o->o.keyAttack);
server.runOnServer(s->check(player(s).level().getBlockState(pos(6)).is(Blocks.STONE),"Construction does not extend mining"));
use(c);server.waitFor(s->player(s).level().getBlockState(pos(6).north()).is(Blocks.COBBLESTONE),100);
setup(c,server,0,7,6,Items.COBBLESTONE,true);use(c);server.waitFor(s->player(s).level().getBlockState(pos(6).north()).is(Blocks.COBBLESTONE),100);
// beta.081: the newly earned half-block works through the actual pick and play packet.
setup(c,server,0,7,6,Items.COBBLESTONE,false);server.runOnServer(s->player(s).teleportTo(.5,1,.25));
c.waitFor(m->Math.abs(m.player.getZ()-.25)<.01,100);c.getInput().lookAt(pos(6));c.waitTicks(5);use(c);
server.waitFor(s->player(s).level().getBlockState(pos(6).north()).is(Blocks.COBBLESTONE),100);
// Outside six blocks, a forged hit is rejected even within the native packet tolerance.
setup(c,server,0,7,6,Items.COBBLESTONE,false);server.runOnServer(s->player(s).teleportTo(.5,1,-.25));
c.waitFor(m->Math.abs(m.player.getZ()+.25)<.01,100);c.getInput().lookAt(pos(6));c.waitTicks(5);
c.runOnClient(m->m.gameMode.useItemOn(m.player,InteractionHand.MAIN_HAND,new BlockHitResult(new Vec3(.5,2.5,6),Direction.NORTH,pos(6),false)));c.waitTicks(15);
server.runOnServer(s->{var p=player(s);check(p.level().getBlockState(pos(6).north()).isAir()&&p.getMainHandItem().getCount()==64,"Six-block maximum is enforced by the server");});
// A deliberately forged out-of-range native packet cannot place or consume an item.
setup(c,server,7,7,8,Items.COBBLESTONE,false);
c.runOnClient(m->m.gameMode.useItemOn(m.player,InteractionHand.MAIN_HAND,new BlockHitResult(new Vec3(.5,2.5,8),Direction.NORTH,pos(8),false)));c.waitTicks(15);
server.runOnServer(s->{var p=player(s);check(p.level().getBlockState(pos(8).north()).isAir()&&p.getMainHandItem().getCount()==64,"Server rejects placement beyond maximum plus native packet tolerance");});
// High Mining + low Construction: mine the far stone, but still cannot place at the same distance.
setup(c,server,7,0,6,Items.COBBLESTONE,false);use(c);c.waitTicks(12);
server.runOnServer(s->check(player(s).level().getBlockState(pos(6).north()).isAir(),"Mining does not extend placement"));
setup(c,server,7,0,6,Items.DIAMOND_PICKAXE,false);
c.getInput().holdKey(o->o.keyAttack);server.waitFor(s->player(s).level().getBlockState(pos(6)).isAir(),140);c.getInput().releaseKey(o->o.keyAttack);
// Low Mining rejects the same block despite the normal interaction highlight.
setup(c,server,0,7,5,Items.DIAMOND_PICKAXE,false);c.getInput().holdKey(o->o.keyAttack);c.waitTicks(30);c.getInput().releaseKey(o->o.keyAttack);
server.runOnServer(s->check(player(s).level().getBlockState(pos(5)).is(Blocks.STONE),"Starting mining reach is reduced"));
// A distant chest is only a building support, not a newly extended container interaction.
setup(c,server,0,7,6,Items.COBBLESTONE,false);server.runOnServer(s->player(s).level().setBlockAndUpdate(pos(6),Blocks.CHEST.defaultBlockState()));c.waitTicks(8);use(c);
server.waitFor(s->player(s).level().getBlockState(pos(6).north()).is(Blocks.COBBLESTONE),100);
server.runOnServer(s->check(player(s).containerMenu==player(s).inventoryMenu,"Far support does not open a container"));
// Offhand building reach must not silently extend a tool held in the main hand.
setup(c,server,0,7,6,Items.FLINT_AND_STEEL,false);server.runOnServer(s->{player(s).setItemInHand(InteractionHand.OFF_HAND,new ItemStack(Items.COBBLESTONE,64));player(s).inventoryMenu.broadcastChanges();});c.waitTicks(10);use(c);
server.waitFor(s->player(s).level().getBlockState(pos(6).north()).is(Blocks.COBBLESTONE),100);
server.runOnServer(s->check(player(s).getMainHandItem().getDamageValue()==0,"Offhand reach does not extend flint and steel"));
// A full plane still places outside the anchor's range, through the existing grouped contract.
setup(c,server,7,7,6,Items.COBBLESTONE,false);server.runOnServer(s->{for(int x=-1;x<=1;x++)for(int y=1;y<=3;y++)player(s).level().setBlockAndUpdate(new BlockPos(x,y,6),Blocks.STONE.defaultBlockState());});c.waitTicks(8);
c.getInput().holdKey(InputConstants.KEY_R);c.waitTicks(4);
c.runOnClient(m->{for(int i=0;i<6;i++)BuildingClient.scroll(-1);});c.waitFor(m->BuildingClient.previewSize()==9,150);
c.getInput().holdKey(o->o.keyUse);server.waitFor(s->player(s).getMainHandItem().getCount()==55,200);c.getInput().releaseKey(o->o.keyUse);c.getInput().releaseKey(InputConstants.KEY_R);
server.runOnServer(s->{for(int x=-1;x<=1;x++)for(int y=1;y<=3;y++)check(player(s).level().getBlockState(new BlockPos(x,y,5)).is(Blocks.COBBLESTONE),"All nine grouped cells placed");});
// Vein mining starts beyond vanilla reach with Mining, using the same frozen-anchor rules.
setup(c,server,7,0,6,Items.DIAMOND_PICKAXE,false);server.runOnServer(s->{for(int x=-1;x<=1;x++)for(int y=1;y<=3;y++)player(s).level().setBlockAndUpdate(new BlockPos(x,y,6),Blocks.STONE.defaultBlockState());});c.waitTicks(8);
c.getInput().holdKey(InputConstants.KEY_R);c.waitFor(m->MiningClient.previewSize()==9,150);c.getInput().holdKey(o->o.keyAttack);
server.waitFor(s->MiningManager.active(player(s)),100);c.getInput().lookAt(new BlockPos(0,4,-3));
server.waitFor(s->player(s).level().getBlockState(pos(6)).isAir(),300);c.getInput().releaseKey(o->o.keyAttack);c.getInput().releaseKey(InputConstants.KEY_R);
server.runOnServer(s->{for(int x=-1;x<=1;x++)for(int y=1;y<=3;y++)check(player(s).level().getBlockState(new BlockPos(x,y,6)).isAir(),"Looking away preserves and completes all nine vein blocks");});
// Water placement uses its own native raycast and must receive the same reach.
setup(c,server,7,7,6,Items.LILY_PAD,false);server.runOnServer(s->{var p=player(s);p.level().setBlockAndUpdate(pos(6),Blocks.AIR.defaultBlockState());p.level().setBlockAndUpdate(new BlockPos(0,1,5),Blocks.WATER.defaultBlockState());});c.waitTicks(8);
c.getInput().lookAt(new BlockPos(0,1,5));c.waitTicks(5);use(c);server.waitFor(s->player(s).level().getBlockState(new BlockPos(0,2,5)).is(Blocks.LILY_PAD),100);
// Prestige/reset, creative and unbound vanilla players keep coherent defaults.
server.runOnServer(s->{var p=player(s);setRanks(p,0,0);check(BuildReach.range(p)==3&&MiningReach.range(p)==3,"Reset immediately restores both starting reaches");p.setGameMode(GameType.CREATIVE);check(BuildReach.range(p)==p.blockInteractionRange()&&MiningReach.range(p)==p.blockInteractionRange(),"Creative retains native ranges");p.setGameMode(GameType.SURVIVAL);ProgressEffects.clear(p);check(BuildReach.range(p)==4.5&&MiningReach.range(p)==4.5,"Non-Sanctuary players keep vanilla ranges");setRanks(p,7,7);});
c.waitFor(m->ProgressionClient.progress.mining()==7&&ProgressionClient.progress.building()==7,200);
c.takeScreenshot("balance080-complete");System.out.println("BALANCE080_PASS: independent reach, native placement/mining, offhand, forged packet, group, locked vein, water, utility isolation, ranks and modifiers");
}
}
private static void curve(ServerPlayer p) {
double[] speed={.75,.85,.95,1,1.075,1.15,1.225,1.30},reach={3,3.5,4,4.5,4.75,5,5.25,5.5},buildReach={3,3.5,4,4.5,4.75,5,5.5,6};
var extra=SanctuaryMod.id("test/balance080");var attribute=p.getAttribute(Attributes.BLOCK_BREAK_SPEED);
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.DIAMOND_PICKAXE));setRanks(p,3,3);
var state=Blocks.STONE.defaultBlockState();float baseline=state.getDestroyProgress(p,p.level(),new BlockPos(0,0,0));
for(int rank=0;rank<=7;rank++) {setRanks(p,rank,rank);near(attribute.getValue(),speed[rank],"Mining speed rank "+rank);near(state.getDestroyProgress(p,p.level(),new BlockPos(0,0,0))/baseline,speed[rank],"Native block breaking progress rank "+rank);near(BuildReach.range(p),buildReach[rank],"Build reach rank "+rank);near(MiningReach.range(p),reach[rank],"Mining reach rank "+rank);}
attribute.addTransientModifier(new AttributeModifier(extra,.5,AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL));setRanks(p,7,7);near(attribute.getValue(),1.95,"Mining composes with external boost");attribute.removeModifier(extra);
var blockRange=p.getAttribute(Attributes.BLOCK_INTERACTION_RANGE);blockRange.addTransientModifier(new AttributeModifier(extra,2,AttributeModifier.Operation.ADD_VALUE));near(BuildReach.range(p),8,"Construction preserves external range bonuses");near(MiningReach.range(p),7.5,"Mining preserves external range bonuses");blockRange.removeModifier(extra);
near(p.blockInteractionRange(),4.5,"Base interactions remain native");near(p.entityInteractionRange(),3,"Entity interactions remain native");
}
private static void setup(ClientGameTestContext c,TestServerContext server,int mining,int building,int z,Item item,boolean offhand) {
server.runOnServer(s->{var p=player(s);MiningManager.forget(p);BuildingManager.forget(p);setRanks(p,mining,building);p.teleportTo(.5,1,.75);
for(int x=-1;x<=1;x++)for(int y=1;y<=3;y++)for(int zz=2;zz<=9;zz++)p.level().setBlockAndUpdate(new BlockPos(x,y,zz),Blocks.AIR.defaultBlockState());
p.level().setBlockAndUpdate(pos(z),Blocks.STONE.defaultBlockState());
p.setItemInHand(InteractionHand.MAIN_HAND,offhand?ItemStack.EMPTY:new ItemStack(item,item.getDefaultMaxStackSize()));p.setItemInHand(InteractionHand.OFF_HAND,offhand?new ItemStack(item,64):ItemStack.EMPTY);p.inventoryMenu.broadcastChanges();
});c.waitFor(m->ProgressionClient.progress.mining()==mining&&ProgressionClient.progress.building()==building&&m.level.getBlockState(pos(z)).is(Blocks.STONE)&&(offhand?m.player.getOffhandItem():m.player.getMainHandItem()).is(item),200);c.waitTicks(8);c.getInput().lookAt(pos(z));c.waitTicks(5);
}
private static void use(ClientGameTestContext c){c.getInput().holdKey(o->o.keyUse);c.waitTicks(2);c.getInput().releaseKey(o->o.keyUse);}
private static void setRanks(ServerPlayer p,int mining,int building){var r=ProgressRecord.INITIAL;var config=ProgressionService.session(p.level().getServer()).config;for(int i=0;i<mining;i++)r=r.buy("mining",r.cost("mining",config));for(int i=0;i<building;i++)r=r.buy("building",r.cost("building",config));p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");}
private static BlockPos pos(int z){return new BlockPos(0,2,z);}
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 near(double actual,double expected,String reason){check(Math.abs(actual-expected)<.00001,reason+" actual="+actual+" expected="+expected);}
private static void check(boolean value,String reason){if(!value)throw new AssertionError(reason);}
}
@@ -0,0 +1,66 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.sky.VortexClouds;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.client.CloudStatus;
import net.minecraft.client.GraphicsPreset;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.level.GameType;
/** Native cloud passes on a new empty laboratory, plus the single connected/cached shape. */
public final class Cloud077ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
for(int diameter:List.of(512,724,1024))geometry(diameter);
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0));
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0));intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Cloud077 "+UUID.randomUUID());s.setSeed("77");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_gametest:vortex")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1500);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(VortexClouds.settings(p.level())==null,"A flat generator never receives a vortex automatically");p.setGameMode(GameType.SPECTATOR);p.teleportTo(0,300,0);
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"weather clear");
});c.waitTicks(12);
c.runOnClient(m->{m.getWindow().setWindowed(1280,800);m.options.guiScale().set(2);m.resizeGui();m.options.cloudRange().set(128);m.options.cloudStatus().set(CloudStatus.FANCY);m.options.fov().set(110);});c.getInput().pressKey(InputConstants.KEY_F1);c.getInput().lookAt(new BlockPos(0,0,0));c.waitTicks(8);
c.takeScreenshot("cloud077-before");int before=c.computeOnClient(m->VortexCloudRenderer.draws());
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();ServerPlayNetworking.send(p,new VortexClouds.Settings(p.level().dimension().identifier(),0,0,724));});
c.waitFor(m->VortexCloudRenderer.draws()>before,100);c.waitTicks(8);c.takeScreenshot("cloud077-fancy-top");c.runOnClient(m->System.out.println("CLOUD077_CAMERA pos="+m.gameRenderer.mainCamera().position()+" pitch="+m.gameRenderer.mainCamera().xRot()+" player="+m.player.position()+" range="+m.options.cloudRange().get()));
int builds=c.computeOnClient(m->VortexCloudRenderer.builds());
c.runOnClient(m->m.level.setTimeFromServer(3000));c.waitTicks(2);c.takeScreenshot("cloud077-rotated");
c.runOnClient(m->m.options.cloudStatus().set(CloudStatus.FAST));c.waitTicks(6);c.takeScreenshot("cloud077-fast");
int oit=c.computeOnClient(m->VortexCloudRenderer.oitDraws());c.runOnClient(m->{m.options.applyGraphicsPreset(GraphicsPreset.FABULOUS);m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.cloudRange().set(128);});c.waitTicks(12);c.takeScreenshot("cloud077-fabulous");check(c.computeOnClient(m->VortexCloudRenderer.oitDraws())>oit,"Fabulous actually uses the OIT cloud passes");
check(c.computeOnClient(m->VortexCloudRenderer.builds())==builds,"Rotation and graphics mode keep cached geometry");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();for(int x=60;x<=140;x++)for(int z=-40;z<=40;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),net.minecraft.world.level.block.Blocks.AIR.defaultBlockState());p.teleportTo(100,-12,0);});c.waitTicks(12);c.getInput().lookAt(new BlockPos(100,1,0));c.waitTicks(4);c.takeScreenshot("cloud077-below");
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(100,1,0));c.waitTicks(12);c.getInput().lookAt(new BlockPos(100,1,100));c.waitTicks(4);c.takeScreenshot("cloud077-inside");
c.runOnClient(m->m.options.cloudStatus().set(CloudStatus.OFF));c.waitTicks(5);int off=c.computeOnClient(m->VortexCloudRenderer.draws());c.waitTicks(8);check(c.computeOnClient(m->VortexCloudRenderer.draws())==off,"Clouds off stops both layers");
c.runOnClient(m->m.options.cloudStatus().set(CloudStatus.FANCY));server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(20000,300,20000));c.waitTicks(15);int far=c.computeOnClient(m->VortexCloudRenderer.draws());c.waitTicks(8);check(c.computeOnClient(m->VortexCloudRenderer.draws())==far,"Disc does not tile across the world");
check(c.computeOnClient(m->VortexCloudRenderer.builds())==builds,"Moving the camera never rebuilds the mesh");c.getInput().pressKey(InputConstants.KEY_F1);
System.out.println("CLOUD077_PASS: connected 512/724/1024 geometry, native fancy/fast/Fabulous, rotation, Y0 crossing, clouds off, finite extent and cache");
}finally{intro.set(false);}
}
private static void geometry(int diameter){
Set<Long> all=new HashSet<>();int radius=(int)Math.ceil(diameter/12.0);
for(int x=-radius;x<radius;x++)for(int z=-radius;z<radius;z++)if(VortexCloudRenderer.occupied(x,z,diameter))all.add(key(x,z));
check(all.size()>2000&&all.size()<25000,"Bounded geometry "+diameter);
Set<Long> seen=new HashSet<>();var queue=new ArrayDeque<Long>();queue.add(all.iterator().next());
while(!queue.isEmpty()){long id=queue.remove();if(!seen.add(id))continue;int x=(int)(id>>32),z=(int)id;for(int[] d:new int[][]{{1,0},{-1,0},{0,1},{0,-1}}){long next=key(x+d[0],z+d[1]);if(all.contains(next)&&!seen.contains(next))queue.add(next);}}
check(seen.size()==all.size(),"Continuous spiral, no disconnected fragments: "+diameter+" "+seen.size()+"/"+all.size());
check(!VortexCloudRenderer.occupied(0,0,diameter)&&!VortexCloudRenderer.occupied(radius,0,diameter),"Open eye and finite outer edge");
System.out.println("CLOUD077_GEOMETRY "+diameter+" "+all.size()+" cells");
}
private static long key(int x,int z){return ((long)x<<32)|(z&0xffffffffL);}
private static void check(boolean value,String reason){if(!value)throw new AssertionError(reason);}
}
@@ -0,0 +1,117 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import com.mojang.blaze3d.vertex.PoseStack;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.inventory.*;
import java.lang.reflect.Proxy;
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.*;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.entity.state.EnderDragonRenderState;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.client.model.monster.dragon.EnderDragonModel;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.CustomData;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
/** Native key events, client/server carry physics and the submitted dragon model's actual head direction. */
public final class Familiar067ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Familiar067 "+UUID.randomUUID());s.setSeed("67");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.getInventory().clearContent();p.getAbilities().invulnerable=true;
p.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.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"));
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"));
c.getInput().holdKey(InputConstants.KEY_K);c.waitFor(m->m.gui.screen() instanceof FamiliarOrdersScreen,100);c.getInput().releaseKey(InputConstants.KEY_K);c.getInput().pressKey(InputConstants.KEY_ESCAPE);
c.runOnClient(m->{orders().setKey(orders().getDefaultKey());KeyMapping.resetMapping();orientation(m);});
System.out.println("FAMILIAR067_KEYS_ORIENTATION_PASS: real H/F/K input, native hand swap restored, native dragon head faces travel in four directions");
for(int size:new int[]{350,650,1500}){
server.runOnServer(s->{var p=player(s);CarryService.detach(p);p.stopRiding();AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);
p.teleportTo(.5,1,.5);p.setItemInHand(InteractionHand.MAIN_HAND,ItemStack.EMPTY);p.setItemInHand(InteractionHand.OFF_HAND,ItemStack.EMPTY);p.setShiftKeyDown(false);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg(size));p.inventoryMenu.broadcastChanges();});c.waitTicks(30);
int id=server.computeOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet!=null,"Equipped dragon spawns");pet.setNoAi(true);pet.setPos(p.position().add(0,0,2));pet.setDeltaMovement(Vec3.ZERO);return pet.getId();});
c.waitFor(m->m.level.getEntity(id)!=null&&m.player.getMainHandItem().isEmpty(),100);c.getInput().holdKey(o->o.keyShift);c.waitTicks(4);click(c,id);c.waitTicks(5);c.getInput().releaseKey(o->o.keyShift);c.waitTicks(5);
server.runOnServer(s->{var p=player(s);check(CarryService.carried(FamiliarService.active(p)),"Dragon is carried via native sneak-click: "+size);p.teleportTo(.5,8,.5);p.setDeltaMovement(0,-.5,0);p.setOnGround(false);p.fallDistance=12;});
c.waitTicks(20);
c.runOnClient(m->{check(CarryGlide.active(m.player),"Local player glides with dragon: "+size);check(m.player.getDeltaMovement().y>=-CarryGlide.DESCENT-.01,"Client descent bounded: "+m.player.getDeltaMovement());check(m.player.fallDistance<.2,"Accumulated fall distance cleared on client");});
server.runOnServer(s->{check(CarryGlide.supported(player(s)),"Server agrees on glide support");check(player(s).fallDistance<.2,"Server fall distance cleared");});
if(size==1500){
double base=server.computeOnServer(s->player(s).getY());c.getInput().holdKey(o->o.keyJump);c.waitTicks(100);
server.runOnServer(s->{check(player(s).getY()>base+8,"Holding jump flies upward with large dragon: "+player(s).getY()+" from "+base);check(player(s).connection.isAcceptingMessages(),"Sustained supported flight is not rejected as floating");});
c.getInput().releaseKey(o->o.keyJump);c.waitTicks(25);double top=server.computeOnServer(s->player(s).getY());c.waitTicks(15);server.runOnServer(s->check(player(s).getY()<top-.5,"Releasing jump resumes a gentle glide"));
c.runOnClient(m->{m.options.setCameraType(CameraType.THIRD_PERSON_BACK);});c.waitTicks(3);c.takeScreenshot("familiar067-large-dragon-flight");
server.runOnServer(s->{var p=player(s);int y=(int)Math.ceil(FamiliarService.active(p).getBoundingBox().maxY)+1;for(int x=-2;x<=2;x++)for(int z=-2;z<=2;z++)p.level().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.STONE.defaultBlockState());});
c.getInput().holdKey(o->o.keyJump);c.waitTicks(40);c.getInput().releaseKey(o->o.keyJump);
server.runOnServer(s->{var p=player(s);check(p.level().noBlockCollision(p,p.getBoundingBox().deflate(.02)),"Lift cannot push the player through a ceiling");});
}else{
c.getInput().holdKey(o->o.keyJump);c.waitTicks(6);c.getInput().releaseKey(o->o.keyJump);
c.runOnClient(m->check(m.player.getDeltaMovement().y<=0,"Small dragon glides but cannot lift"));
}
server.runOnServer(s->{var p=player(s);CarryService.detach(p);p.teleportTo(.5,1,.5);});c.waitTicks(5);
System.out.println("FAMILIAR067_CARRY_PASS size="+size);
}
// The existing colossal riding path must still work for the dragon itself.
server.runOnServer(s->{var p=player(s);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg(3500));p.teleportTo(5.5,1,.5);});c.waitTicks(30);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);pet.setPos(p.position().add(0,1,2));p.setShiftKeyDown(false);check(CarryService.interact(p,pet),"Colossal dragon mounts instead of being carried");});c.waitTicks(5);
double base=server.computeOnServer(s->FamiliarService.active(player(s)).getY());c.getInput().holdKey(o->o.keyJump);c.waitTicks(20);c.getInput().releaseKey(o->o.keyJump);
server.runOnServer(s->check(FamiliarService.active(player(s)).getY()>base+2,"Colossal dragon flies with native jump input"));
System.out.println("FAMILIAR067_PASS: native shortcuts, model orientation, tiny/small glide, large carried flight and colossal riding");
}finally{intro.set(false);c.getInput().releaseKey(o->o.keyShift);c.getInput().releaseKey(o->o.keyJump);}
}
private static void orientation(Minecraft mc){
for(int yaw:new int[]{0,90,180,270}){
var pet=FamiliarService.TYPE.create(mc.level,EntitySpawnReason.COMMAND);pet.setId(300000+yaw);pet.bind(null,egg(1000));pet.setPos(mc.player.position().add(0,5,8));pet.setYRot(yaw);pet.yRotO=yaw;pet.yBodyRot=pet.yBodyRotO=pet.yHeadRot=pet.yHeadRotO=yaw;
var renderer=(FamiliarRenderer)mc.getEntityRenderDispatcher().getRenderer(pet);var state=renderer.createRenderState(pet,1);var checked=new AtomicBoolean();
SubmitNodeCollector collector=(SubmitNodeCollector)Proxy.newProxyInstance(SubmitNodeCollector.class.getClassLoader(),new Class<?>[]{SubmitNodeCollector.class},(proxy,method,args)->{
if(method.getName().equals("order"))return proxy;
if(method.getName().equals("submitModel")&&args[0] instanceof EnderDragonModel dragon&&checked.compareAndSet(false,true)){
dragon.setupAnim((EnderDragonRenderState)args[1]);var pose=new PoseStack();pose.mulPose(((PoseStack)args[2]).last().pose());dragon.root().translateAndRotate(pose);
var head=dragon.root().getChild("head");var body=dragon.root().getChild("body");
var a=pose.last().pose().transformPosition(new org.joml.Vector3f(head.x/16,head.y/16,head.z/16));var b=pose.last().pose().transformPosition(new org.joml.Vector3f(body.x/16,body.y/16,body.z/16));
var forward=Vec3.directionFromRotation(0,yaw);double dot=(a.x-b.x)*forward.x+(a.z-b.z)*forward.z;
check(dot>0,"Submitted dragon head points forwards at yaw "+yaw+", dot="+dot);
}return null;
});
renderer.submit(state,new PoseStack(),collector,new CameraRenderState());check(checked.get(),"Actual native dragon model was submitted");
}
}
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 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);});}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,147 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.companion.CompanionService;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.CustomData;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.Vec3;
/** Native server AI and mount movement in an isolated client-created flat world. */
public final class Familiar071ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave save;
var savedId=new AtomicReference<UUID>();var savedPersonality=new AtomicReference<FamiliarPersonality>();
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Familiar071 "+UUID.randomUUID());s.setSeed("71");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()){
save=world.getWorldSave();c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.getAbilities().invulnerable=true;p.getInventory().clearContent();p.teleportTo(.5,1,.5);
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set night");
for(int x=-16;x<=16;x++)for(int z=-16;z<=16;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
distributions(s);
});
for(var personality:FamiliarPersonality.values()){
var victim=new AtomicReference<Mob>();
server.runOnServer(s->{var p=player(s);equip(p,Items.WOLF_SPAWN_EGG,personality,1000);FamiliarService.active(p).setPos(.5,1,1.5);victim.set(enemy(p,7.5,1,1.5));});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);var target=victim.get();check((FamiliarBattle.target(p)!=null)==(personality==FamiliarPersonality.AGGRESSIVE),"Proximity reaction: "+personality);
FamiliarBattle.cancel(p);FamiliarBattle.state(p).automaticAfter=0;
FamiliarBattle.damageEvent(target,p.damageSources().playerAttack(p),1);
check((FamiliarBattle.target(p)!=null)==(personality==FamiliarPersonality.AGGRESSIVE),"Owner attack reaction: "+personality);
check(FamiliarBattle.fighting(p),"Combat lock remains active even if personality refuses to attack: "+personality);
FamiliarBattle.cancel(p);FamiliarBattle.damageEvent(p,p.damageSources().mobAttack(target),1);
check((FamiliarBattle.target(p)!=null)==(personality!=FamiliarPersonality.PACIFIST),"Owner hurt reaction: "+personality);
FamiliarBattle.cancel(p);FamiliarService.active(p).hurtServer(p.level(),p.damageSources().mobAttack(target),1);
check((FamiliarBattle.target(p)!=null)==(personality!=FamiliarPersonality.PACIFIST),"Pet hurt reaction: "+personality);
if(personality==FamiliarPersonality.PACIFIST)check(FamiliarBattle.state(p).evadeUntil>p.tickCount,"Pacifist chooses retreat");
FamiliarBattle.engage(p,target,false);check(FamiliarBattle.state(p).explicitTarget,"Manual attack is explicit: "+personality);
});c.waitTicks(100);
server.runOnServer(s->{var p=player(s);check(victim.get().getHealth()<100,"Actual navigation and damage on explicit order: "+personality+" distance="+FamiliarService.active(p).distanceTo(victim.get()));
System.out.println("FAMILIAR071_PERSONALITY_PASS "+personality+" targetHealth="+victim.get().getHealth()+" pursuit="+personality.pursuitSpeed());victim.get().discard();FamiliarBattle.cancel(p);
});
}
// Support species must really attack, rather than repeatedly trying a shield on an enemy.
var villagerTarget=new AtomicReference<Mob>();
server.runOnServer(s->{var p=player(s);equip(p,Items.VILLAGER_SPAWN_EGG,FamiliarPersonality.AGGRESSIVE,1000);FamiliarService.active(p).setPos(.5,1,1.5);villagerTarget.set(enemy(p,6.5,1,1.5));});c.waitTicks(120);
server.runOnServer(s->{var p=player(s);check(villagerTarget.get().getHealth()<100,"Aggressive villager initiates and really strikes without shield supply");check(!FamiliarBattle.state(p).explicitTarget,"Automatic technique remains automatic");
var second=enemy(p,1.5,1,.5);FamiliarBattle.engage(p,villagerTarget.get(),false);FamiliarBattle.automatic(p,second,FamiliarPersonality.Trigger.OWNER_HURT);check(FamiliarBattle.target(p)==villagerTarget.get(),"Explicit target is not overwritten by another attacker");second.discard();villagerTarget.get().discard();FamiliarBattle.cancel(p);
savedId.set(FamiliarBattle.bond(p).id());savedPersonality.set(FamiliarPersonality.of(FamiliarBattle.bond(p)));
var egg=FamiliarBattle.egg(p);egg.set(DataComponents.CUSTOM_NAME,Component.literal("Mistral"));FamiliarBattle.update(p,FamiliarBattle.bond(p).charge(100,FamiliarBattle.profile(p).health()));FamiliarBattle.flush(s);
});c.waitTicks(25);
for(String locale:List.of("fr_fr","en_us")){
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());FamiliarClient.open(null);});c.waitTicks(5);c.takeScreenshot("familiar071-personality-"+locale);c.runOnClient(m->m.gui.setScreen(null));
}
System.out.println("FAMILIAR071_VILLAGER_PASS: automatic melee, manual target priority and FR/EN character sheet");
// Native input drives two climb-capable mounts, with a ceiling and no open-air flight.
for(var item:List.of(Items.SPIDER_SPAWN_EGG,Items.CAVE_SPIDER_SPAWN_EGG)){
server.runOnServer(s->{var p=player(s);equip(p,item,FamiliarPersonality.PACIFIST,3500);p.teleportTo(.5,1,.5);
for(int x=-3;x<=3;x++)for(int y=1;y<=12;y++)p.level().setBlockAndUpdate(new BlockPos(x,y,4),Blocks.STONE.defaultBlockState());
for(int x=-3;x<=3;x++)for(int z=-1;z<=4;z++)p.level().setBlockAndUpdate(new BlockPos(x,10,z),Blocks.STONE.defaultBlockState());
});c.waitTicks(15);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);pet.setPos(.5,1,1.5);pet.setDeltaMovement(Vec3.ZERO);p.setShiftKeyDown(false);check(CarryService.interact(p,pet)&&p.getVehicle()==pet,"Mount colossal spider");});c.waitTicks(5);
c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);});c.getInput().holdKey(o->o.keyUp);c.waitTicks(70);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet.getY()>3,"Spider climbs real wall: "+item+" pos="+pet.position());check(p.level().noBlockCollision(p,p.getBoundingBox().deflate(.02)),"Rider cannot enter ceiling");check(p.level().noBlockCollision(pet,pet.getBoundingBox().deflate(.02)),"Mount cannot enter wall");System.out.println("FAMILIAR071_CLIMB_PASS "+item+" y="+pet.getY());});
c.takeScreenshot("familiar071-climb-"+item);c.getInput().releaseKey(o->o.keyUp);c.waitTicks(10);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);pet.setPos(8.5,1,1.5);pet.setDeltaMovement(Vec3.ZERO);});c.waitTicks(5);c.getInput().holdKey(o->o.keyUp);c.waitTicks(25);c.getInput().releaseKey(o->o.keyUp);
server.runOnServer(s->{var p=player(s);check(FamiliarService.active(p).getY()<1.5,"Climbing never lifts in open air");p.stopRiding();});
}
server.runOnServer(s->{var p=player(s);p.teleportTo(.5,1,.5);equip(p,Items.STRIDER_SPAWN_EGG,FamiliarPersonality.PACIFIST,3500);
for(int x=-14;x<=-4;x++)for(int z=-4;z<=12;z++)for(int y=1;y<=3;y++)p.level().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.LAVA.defaultBlockState());
var pet=FamiliarService.active(p);pet.setPos(-9.5,3.5,.5);pet.setDeltaMovement(Vec3.ZERO);p.teleportTo(-9.5,5.5,.5);p.setShiftKeyDown(false);check(CarryService.interact(p,pet)&&p.getVehicle()==pet,"Mount colossal Strider");p.getAbilities().invulnerable=false;
});c.waitTicks(20);c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);});c.getInput().holdKey(o->o.keyUp);c.waitTicks(20);c.getInput().releaseKey(o->o.keyUp);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet.getY()>=3.4,"Strider stays on lava surface: "+pet.position());check(pet.getZ()>2,"Strider actually moves across lava");check(!p.isOnFire()&&!p.isInLava(),"Rider stays safely above lava without fire immunity buff");check(!p.hasEffect(net.minecraft.world.effect.MobEffects.FIRE_RESISTANCE),"No invented rider fire resistance");p.getAbilities().invulnerable=true;p.stopRiding();p.teleportTo(.5,1,.5);
System.out.println("FAMILIAR071_STRIDER_PASS: native lava support and real directional input");
});
for(var item:List.of(Items.NAUTILUS_SPAWN_EGG,Items.ZOMBIE_NAUTILUS_SPAWN_EGG))for(int size:new int[]{2500,6000}){
server.runOnServer(s->{var p=player(s);p.getAbilities().invulnerable=true;p.removeEffect(net.minecraft.world.effect.MobEffects.BREATH_OF_THE_NAUTILUS);p.teleportTo(10.5,1,10.5);equip(p,item,FamiliarPersonality.PACIFIST,size);
for(int x=6;x<=15;x++)for(int z=6;z<=15;z++)for(int y=1;y<=12;y++)p.level().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.WATER.defaultBlockState());
check(!p.hasEffect(net.minecraft.world.effect.MobEffects.BREATH_OF_THE_NAUTILUS),"Equipping alone does not grant mount breath");
var pet=FamiliarService.active(p);pet.setPos(10.5,2,10.5);pet.setDeltaMovement(Vec3.ZERO);p.setShiftKeyDown(false);check(CarryService.interact(p,pet)&&p.getVehicle()==pet,"Mount Nautilus "+size);
check(p.hasEffect(net.minecraft.world.effect.MobEffects.BREATH_OF_THE_NAUTILUS),"Native effect applies immediately on mount");
});c.waitTicks(8);
server.runOnServer(s->{var p=player(s);check(p.isUnderWater(),"Rider is actually submerged");p.getAbilities().invulnerable=false;p.setAirSupply(40);});c.waitTicks(90);
server.runOnServer(s->{var p=player(s);check(p.getVehicle()==FamiliarService.active(p),"Underwater rider stays mounted");check(p.getAirSupply()==40,"Native breath freezes air across multiple refreshes: "+item+" size="+size+" air="+p.getAirSupply());
check(!p.hasEffect(net.minecraft.world.effect.MobEffects.WATER_BREATHING),"Uses Breath of the Nautilus, not regenerating Water Breathing");p.stopRiding();p.teleportTo(10.5,2,10.5);
});c.waitTicks(72);
server.runOnServer(s->{var p=player(s);check(!p.hasEffect(net.minecraft.world.effect.MobEffects.BREATH_OF_THE_NAUTILUS),"Effect expires naturally after dismount");check(p.getAirSupply()<40,"Air consumption resumes after dismount");p.getAbilities().invulnerable=true;p.setAirSupply(p.getMaxAirSupply());p.teleportTo(.5,1,.5);
System.out.println("FAMILIAR071_NAUTILUS_PASS "+item+" size="+size+" native frozen air and dismount expiry");
});
}
server.runOnServer(s->{var p=player(s);equip(p,Items.VILLAGER_SPAWN_EGG,FamiliarPersonality.AGGRESSIVE,1000);savedId.set(FamiliarBattle.bond(p).id());savedPersonality.set(FamiliarPersonality.of(FamiliarBattle.bond(p)));FamiliarBattle.flush(s);s.saveEverything(true,true,true);});
}finally{intro.set(false);c.getInput().releaseKey(o->o.keyUp);}
try(var world=save.open()){
c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
world.getServer().runOnServer(s->{var b=FamiliarBattle.bond(player(s));check(savedId.get().equals(b.id())&&savedPersonality.get()==FamiliarPersonality.of(b),"Individual and personality survive a real world reopen");});
}
System.out.println("FAMILIAR071_PASS: four automatic response policies, explicit damage, villager exceptions, 88 species distributions, native spider/cave-spider climbing, Nautilus/Zombie Nautilus rider breath at both size limits, Strider lava and persistence");
}
private static void distributions(MinecraftServer s){
var random=new Random(71);var profiles=FamiliarBattle.world(s).catalog.profiles();check(profiles.size()==88,"88 profiles remain loaded");
for(var profile:profiles){var counts=new EnumMap<FamiliarPersonality,Integer>(FamiliarPersonality.class);
for(int i=0;i<4000;i++){var id=new UUID(random.nextLong(),random.nextLong());var p=FamiliarPersonality.of(id,profile.id());counts.merge(p,1,Integer::sum);
var bond=FamiliarBond.create(id,profile,0,"test",UUID.randomUUID());check(p==FamiliarPersonality.of(bond.charge(100,profile.health()).remember("journey","test")),"Levels and memories preserve personality");
}
check(counts.size()==4,"Every species allows exceptions: "+profile.id());
if(profile.id().equals("sanctuary:villager"))check(counts.get(FamiliarPersonality.PACIFIST)>1800,"Villagers tend to be pacifist");
if(profile.id().equals("sanctuary:vindicator"))check(counts.get(FamiliarPersonality.AGGRESSIVE)>2500,"Vindicators tend to be aggressive");
}
System.out.println("FAMILIAR071_DISTRIBUTIONS_PASS: 352000 deterministic identities across 88 species");
}
private static void equip(ServerPlayer p,Item item,FamiliarPersonality personality,int size){
p.stopRiding();CarryService.detach(p);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);p.removeAttached(FamiliarBattle.COMBAT_LOCK);p.removeAttached(FamiliarBattle.LAST_FAMILIAR);
var egg=new ItemStack(item);var scale=new CompoundTag();scale.putInt("schema",1);scale.putInt("per_mille",size);CustomData.update(DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,scale));
var profile=FamiliarBattle.world(p.level().getServer()).catalog.forEgg(egg);UUID id;do{id=UUID.randomUUID();}while(FamiliarPersonality.of(id,profile.id())!=personality);
FamiliarBattle.world(p.level().getServer()).store.put(FamiliarBond.create(id,profile,0,"test",p.getUUID()));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,FamiliarIdentity.assign(egg,id));
CompanionService.tick(p);p.tickCount+=10-p.tickCount%10;FamiliarService.tick(p);check(FamiliarService.active(p)!=null,"Familiar spawned: "+item);p.inventoryMenu.broadcastChanges();
}
private static Mob enemy(ServerPlayer p,double x,double y,double z){var e=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);e.setNoAi(true);e.getAttribute(Attributes.MAX_HEALTH).setBaseValue(100);e.setHealth(100);e.setPos(x,y,z);p.level().addFreshEntity(e);return e;}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,100 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import fr.koka.sanctuary.mixin.MultiblockFurnaceAccess;
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.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.*;
import net.minecraft.world.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.entity.player.*;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.phys.*;
public final class Fourneau085ClientChecks implements FabricClientGameTest {
private static final BlockPos ORIGIN=new BlockPos(15,80,15);
private static MultiblockData.Machine oven;
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Fourneau085 "+UUID.randomUUID());s.setSeed("85");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var l=p.level();
p.setGameMode(GameType.CREATIVE);p.getAbilities().flying=true;p.onUpdateAbilities();
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
for(int x=8;x<25;x++)for(int z=8;z<25;z++)l.setBlockAndUpdate(new BlockPos(x,79,z),Blocks.STONE.defaultBlockState());
for(int x=0;x<3;x++)for(int y=0;y<3;y++)for(int z=0;z<3;z++)l.setBlockAndUpdate(ORIGIN.offset(x,y,z),Blocks.FURNACE.defaultBlockState());
((FurnaceBlockEntity)l.getBlockEntity(ORIGIN)).setItem(0,new ItemStack(Items.DIAMOND,17));
p.teleportTo(22.5,83,9.5);
});
c.waitFor(m->m.level.getBlockState(ORIGIN).is(Blocks.FURNACE)&&m.player.getY()>80,200);
c.runOnClient(m->{if(!m.gui.hud.isHidden())m.gui.hud.toggle();m.options.bobView().set(false);m.options.fov().set(60);m.getWindow().setWindowed(1280,800);});
c.getInput().lookAt(ORIGIN.offset(1,1,1));c.waitTicks(15);c.takeScreenshot("fourneau085-before");
for(var facing:Direction.Plane.HORIZONTAL){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var l=p.level();
if(oven!=null)Multiblocks.dissolve(l,oven);
oven=new MultiblockData.Machine(UUID.randomUUID(),ORIGIN,true,facing);
check(Multiblocks.assemble(p,oven),"Assembly for "+facing);
var right=facing.getCounterClockWise();
p.teleportTo(16.5+facing.getStepX()*7+right.getStepX()*5,83,16.5+facing.getStepZ()*7+right.getStepZ()*5);
});
c.waitFor(m->FourneauModels.modelAt(ORIGIN)!=null&&FourneauModels.modelAt(ORIGIN).getPath().contains(facing.getName()),200);
c.waitTicks(8);c.getInput().lookAt(ORIGIN.offset(1,1,1));c.waitTicks(15);
c.runOnClient(m->{for(int x=0;x<3;x++)for(int y=0;y<3;y++)for(int z=0;z<3;z++)check(FourneauModels.modelAt(ORIGIN.offset(x,y,z))!=null,"All 27 components synchronized across four chunks");});
c.takeScreenshot("fourneau085-"+facing.getName());
}
// Resource reload must retain the synchronized appearance and rebuild native models.
var reload=c.computeOnClient(m->m.reloadResourcePacks());c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(10);
c.takeScreenshot("fourneau085-reload");
c.runOnClient(m->{var before=FourneauModels.modelAt(ORIGIN);
FourneauModels.apply(new FourneauAppearance.Update(net.minecraft.resources.Identifier.withDefaultNamespace("the_nether"),net.minecraft.world.level.ChunkPos.containing(ORIGIN),ORIGIN,Direction.NORTH,FourneauAppearance.Action.CLEAR_CHUNK));
check(before.equals(FourneauModels.modelAt(ORIGIN)),"Other dimension cannot erase the current facade");});
server.runOnServer(s->{var l=s.getPlayerList().getPlayers().getFirst().level();var furnace=(FurnaceBlockEntity)l.getBlockEntity(ORIGIN.offset(1,0,0));
furnace.setItem(0,new ItemStack(Items.RAW_IRON,64));furnace.setItem(1,new ItemStack(Items.COAL,64));});
c.waitFor(m->m.level.getBlockState(ORIGIN.offset(1,0,0)).getValue(AbstractFurnaceBlock.LIT),200);c.waitTicks(10);
c.runOnClient(m->check(FourneauModels.modelAt(ORIGIN)!=null,"Lit updates retain assembled model"));c.takeScreenshot("fourneau085-lit");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var l=p.level();Multiblocks.dissolve(l,oven);
check(((FurnaceBlockEntity)l.getBlockEntity(ORIGIN)).getItem(0).getCount()==17,"Dissociation retains original furnace contents");});
c.waitFor(m->FourneauModels.modelAt(ORIGIN)==null,200);c.waitTicks(15);c.takeScreenshot("fourneau085-separated");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(Multiblocks.assemble(p,oven),"Reassemble");});
c.waitFor(m->FourneauModels.modelAt(ORIGIN)!=null,200);
// Real chunk unload/re-entry exercises native PlayerChunkSender synchronization.
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(400.5,83,400.5));
c.waitFor(m->FourneauModels.modelAt(ORIGIN)==null,500);
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(22.5,83,9.5));
c.waitFor(m->FourneauModels.modelAt(ORIGIN)!=null&&m.level.getBlockState(ORIGIN).is(Blocks.FURNACE),500);
c.getInput().lookAt(ORIGIN.offset(1,1,1));c.waitTicks(15);c.takeScreenshot("fourneau085-return");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.level().destroyBlock(ORIGIN.offset(2,2,2),true);});
c.waitFor(m->FourneauModels.modelAt(ORIGIN)==null,200);c.waitTicks(10);c.takeScreenshot("fourneau085-broken");
System.out.println("FOURNEAU085_PASS: four orientations, four chunks, native models, resource reload, unload/re-entry, stock preservation, dismantle and break");
}
}
private static net.minecraft.client.input.KeyEvent enter(){return new net.minecraft.client.input.KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,96 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import fr.koka.sanctuary.mixin.MultiblockFurnaceAccess;
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.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.*;
import net.minecraft.world.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.entity.player.*;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.phys.*;
public final class Fut086ClientChecks implements FabricClientGameTest {
private static final BlockPos ORIGIN=new BlockPos(15,84,15),OVEN=new BlockPos(23,84,15);
private static MultiblockData.Machine cask,oven;
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Fut086 "+UUID.randomUUID());s.setSeed("86");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var l=p.level();
p.setGameMode(GameType.CREATIVE);p.getAbilities().flying=true;p.onUpdateAbilities();
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
for(int x=8;x<31;x++)for(int z=8;z<25;z++)l.setBlockAndUpdate(new BlockPos(x,79,z),Blocks.STONE.defaultBlockState());
cask=new MultiblockData.Machine(UUID.randomUUID(),ORIGIN,false,Direction.NORTH);
oven=new MultiblockData.Machine(UUID.randomUUID(),OVEN,true,Direction.NORTH);
for(var pos:cask.members())l.setBlockAndUpdate(pos,Blocks.BARREL.defaultBlockState());
for(var pos:oven.members())l.setBlockAndUpdate(pos,Blocks.FURNACE.defaultBlockState());
var stock=new MultiblockContainer(l,cask);stock.setItem(0,new ItemStack(Items.DIAMOND,17));stock.setItem(728,new ItemStack(Items.EMERALD,23));
p.teleportTo(22.5,87,9.5);
});
c.waitFor(m->m.level.getBlockState(ORIGIN).is(Blocks.BARREL)&&m.player.getY()>84,200);
c.runOnClient(m->{if(!m.gui.hud.isHidden())m.gui.hud.toggle();m.options.bobView().set(false);m.options.fov().set(60);m.getWindow().setWindowed(1280,800);});
c.getInput().lookAt(ORIGIN.offset(1,1,1));c.waitTicks(15);c.takeScreenshot("fut086-before");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(Multiblocks.assemble(p,cask),"Cask assembles");check(Multiblocks.assemble(p,oven),"Furnace still assembles");});
c.waitFor(m->FourneauModels.modelAt(ORIGIN)!=null&&FourneauModels.modelAt(OVEN)!=null,200);c.waitTicks(15);
c.runOnClient(m->{check(FourneauModels.modelAt(ORIGIN).getPath().startsWith("block/fut/"),"Cask packet selects its own textures");check(FourneauModels.modelAt(OVEN).getPath().startsWith("block/fourneau/"),"Furnace keeps its original textures");});
c.takeScreenshot("fut086-top-side");
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(22.5,79,9.5));
c.waitFor(m->m.player.getY()<81,200);c.getInput().lookAt(ORIGIN.offset(1,1,1));c.waitTicks(12);c.takeScreenshot("fut086-bottom-side");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(16.5,84,12.5);Multiblocks.open(p,cask,ORIGIN,0,"",0);});
c.waitFor(m->m.gui.screen() instanceof MultiblockScreen,200);c.waitTicks(5);c.takeScreenshot("fut086-inventory");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var menu=(MultiblockMenu)p.containerMenu;
check(menu.getSlot(0).remove(7).getCount()==7,"Shared inventory remains accessible");
var stock=new MultiblockContainer(p.level(),cask);check(stock.getContainerSize()==729&&stock.getItem(0).getCount()==10&&stock.getItem(728).getCount()==23,"Native stock spans all 729 cells");
p.closeContainer();p.teleportTo(22.5,87,9.5);});
c.waitFor(m->m.gui.screen()==null&&m.player.getY()>84,200);c.getInput().lookAt(ORIGIN.offset(1,1,1));
var reload=c.computeOnClient(m->m.reloadResourcePacks());c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(10);c.takeScreenshot("fut086-reload");
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(400.5,87,400.5));
c.waitFor(m->FourneauModels.modelAt(ORIGIN)==null&&FourneauModels.modelAt(OVEN)==null,500);
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(22.5,87,9.5));
c.waitFor(m->FourneauModels.modelAt(ORIGIN)!=null&&FourneauModels.modelAt(OVEN)!=null&&m.level.getBlockState(ORIGIN).is(Blocks.BARREL),500);
c.runOnClient(m->{for(var pos:cask.members())check(FourneauModels.modelAt(pos)!=null,"All 27 cask tiles return across four chunks");});
c.getInput().lookAt(ORIGIN.offset(1,1,1));c.waitTicks(12);c.takeScreenshot("fut086-return");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();Multiblocks.dissolve(p.level(),cask);
var stock=new MultiblockContainer(p.level(),cask);check(stock.getItem(0).getCount()==10&&stock.getItem(728).getCount()==23,"Dismantle preserves real stock");});
c.waitFor(m->FourneauModels.modelAt(ORIGIN)==null,200);c.waitTicks(12);c.takeScreenshot("fut086-separated");
c.runOnClient(m->check(FourneauModels.modelAt(OVEN)!=null,"Dismantling cask does not erase neighboring oven facade"));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(Multiblocks.assemble(p,cask),"Reassemble");});
c.waitFor(m->FourneauModels.modelAt(ORIGIN)!=null,200);
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().level().destroyBlock(ORIGIN.offset(2,2,2),true));
c.waitFor(m->FourneauModels.modelAt(ORIGIN)==null,200);c.runOnClient(m->check(FourneauModels.modelAt(OVEN)!=null,"Break stays scoped to its machine"));
System.out.println("FUT086_PASS: complete top/side/bottom, mixed furnace and cask sync, resource reload, native chunk re-entry, 729-slot inventory, dismantle and break");
}
}
private static net.minecraft.client.input.KeyEvent enter(){return new net.minecraft.client.input.KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,64 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import jdk.jfr.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave;
import net.fabricmc.fabric.api.event.lifecycle.v1.*;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
/** Observation only: fixed seed, full native world creation, same JVM and fresh development saves. */
public final class Loading063ClientAudit implements FabricClientGameTest {
private static volatile long origin;
private static volatile String scenario="menu";
private static final List<String> EVENTS=new java.util.concurrent.CopyOnWriteArrayList<>();
private static void mark(String event){String row=scenario+"\t"+((System.nanoTime()-origin)/1_000_000)+"\t"+event;EVENTS.add(row);System.out.println("LOADING063\t"+row);}
public void runTest(ClientGameTestContext c){
origin=System.nanoTime();mark("test starts; JVM max heap="+Runtime.getRuntime().maxMemory()+"; processors="+Runtime.getRuntime().availableProcessors()+"; launch="+ProcessHandle.current().info().startInstant().orElse(null));
var observing=new AtomicBoolean(true);var last=new AtomicReference<>("");var hello=new AtomicBoolean();var introFinished=new AtomicBoolean();
ClientTickEvents.END_CLIENT_TICK.register(m->{if(!observing.get())return;String screen=m.gui.screen()==null?"world":m.gui.screen().getClass().getSimpleName();if(!screen.equals(last.getAndSet(screen)))mark("screen "+screen);
if(m.gui.screen() instanceof IntroScreen intro&&intro.isFinished()&&introFinished.compareAndSet(false,true))mark("intro finished; waiting under flash");
if(m.gui.screen() instanceof HelloWorldScreen s&&hello.compareAndSet(false,true)){
mark("hello ready");IntroClient.setQuickTestIntro(false);Starter039TestSupport.selectFirst(s);
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());
}
});
ServerLifecycleEvents.SERVER_STARTING.register(s->{if(observing.get())mark("server starting; initialized="+s.getWorldData().overworldData().isInitialized());});
ServerLevelEvents.LOAD.register((s,l)->{if(observing.get())mark("level loaded "+l.dimension().identifier());});
ServerLifecycleEvents.SERVER_STARTED.register(s->{if(observing.get())mark("server started");});
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;});
TestWorldSave island=null;
try{
Files.createDirectories(Path.of("loading063"));
for(String preset:List.of("sanctuary:sanctuary","minecraft:flat","minecraft:normal")){
scenario=preset.replace(':','-');hello.set(false);introFinished.set(false);last.set("");origin=System.nanoTime();mark("create requested; seed=42; structures=true; render=8; simulation=8");
try(var recording=new Recording(Configuration.getConfiguration("profile"))){recording.start();
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Loading063 "+UUID.randomUUID());s.setSeed("42");s.setAllowCommands(true);s.setGenerateStructures(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals(preset)).findFirst().orElseThrow());}).create()){
if(preset.startsWith("sanctuary:"))island=world.getWorldSave();
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),3000);mark("playable");
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;mark("mode="+(fr.koka.sanctuary.progression.ProgressionService.session(s)!=null)+"; generator="+s.overworld().getChunkSource().getGenerator().getClass().getSimpleName());});
c.waitTicks(40);c.takeScreenshot("loading063-"+scenario);
}recording.stop();recording.dump(Path.of("loading063",scenario+".jfr"));
}
if(preset.startsWith("sanctuary:")){
scenario="sanctuary-reopen";hello.set(false);introFinished.set(false);last.set("");origin=System.nanoTime();mark("open requested");
try(var recording=new Recording(Configuration.getConfiguration("profile"))){recording.start();
try(var world=island.open()){c.waitFor(m->m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),3000);mark("playable");c.waitTicks(20);}
recording.stop();recording.dump(Path.of("loading063",scenario+".jfr"));
}
}
}
Files.write(Path.of("loading063","timings.tsv"),EVENTS);System.out.println("LOADING063_AUDIT_PASS");
}catch(Exception e){throw new AssertionError(e);}finally{observing.set(false);}
}
private static KeyEvent enter(){return new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);}
}
@@ -0,0 +1,125 @@
package fr.koka.sanctuary.client;
import com.google.gson.JsonParser;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.loading.*;
import fr.koka.sanctuary.worldgen.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.GenericMessageScreen;
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;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
public final class Loading064ClientChecks implements FabricClientGameTest {
private record Fixture(int value) {}
public void runTest(ClientGameTestContext c) {
cacheFailures();
var observing=new AtomicBoolean(true);var lastHello=new AtomicReference<HelloWorldScreen>();
var phases=new java.util.HashSet<String>();
ClientTickEvents.END_CLIENT_TICK.register(m->{if(!observing.get())return;
if(m.gui.screen() instanceof HelloWorldScreen s&&lastHello.getAndSet(s)!=s){
IntroClient.setQuickTestIntro(false);Starter039TestSupport.selectFirst(s);
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());
}
});
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;});
var baseline=new AtomicReference<Evidence>();var directory=new AtomicReference<Path>();TestWorldSave save;
long began=System.nanoTime();
try {
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Loading064 "+UUID.randomUUID());s.setSeed("42");s.setAllowCommands(true);s.setGenerateStructures(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());}).create()) {
save=world.getWorldSave();
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),3000);
System.out.println("LOADING064_COLD_PLAYABLE_MS="+(System.nanoTime()-began)/1_000_000);
world.getServer().runOnServer(s->{var g=(SanctuaryChunkGenerator)s.overworld().getChunkSource().getGenerator();
check(g.planCache().writes()>0,"Cold world saved derived plans");directory.set(g.planCache().directory());baseline.set(evidence(g));
s.getPlayerList().getPlayers().getFirst().getAbilities().invulnerable=true;
});
phases.addAll(PreloadProgress.observedPhases());
c.takeScreenshot("loading064-arrival");
c.runOnClient(m->{PreloadProgress.begin(m.getSingleplayerServer());PreloadProgress.stage(m.getSingleplayerServer(),"water");m.gui.setScreen(new GenericMessageScreen(Component.translatable("menu.preparingSpawn")));});
c.waitTicks(3);c.takeScreenshot("loading064-preload-en");
c.runOnClient(m->{m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());PreloadProgress.stage(m.getSingleplayerServer(),"heritage");});
c.waitTicks(3);c.takeScreenshot("loading064-preload-fr");
c.runOnClient(m->{m.gui.setScreen(null);PreloadProgress.clear(m.getSingleplayerServer());});
}
for(int pass=0;pass<2;pass++) {
if(pass==1)try(var files=Files.list(directory.get())) {
Path core=files.filter(p->p.getFileName().toString().startsWith("core-")).findFirst().orElseThrow();Files.writeString(core,"interrupted cache write");
}
began=System.nanoTime();
try(var world=save.open()) {
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),3000);
System.out.println("LOADING064_"+(pass==0?"WARM":"RECOVERED")+"_PLAYABLE_MS="+(System.nanoTime()-began)/1_000_000);
final int index=pass;
world.getServer().runOnServer(s->{var g=(SanctuaryChunkGenerator)s.overworld().getChunkSource().getGenerator();
check(g.planCache().hits()>0,"Reopened world read disk cache");
if(index==0)check(g.planCache().misses()==0&&g.planCache().writes()==0,"Warm reopen restores all plans without recomputing");
if(index==1)check(g.planCache().misses()>0&&g.planCache().writes()>0,"Corrupted core cache recomputed and replaced");
var now=evidence(g);check(baseline.get().equals(now),"Same plans, fluid geometry, block writes, loot and protection after restore/recovery");
System.out.println("LOADING064_CACHE_STATS hits="+g.planCache().hits()+" misses="+g.planCache().misses()+" writes="+g.planCache().writes()+" blockWrites="+now.writes()+" blockHash="+now.blocks());
});
check(PreloadProgress.current()==null,"Progress cleared after admission");
}
}
check(phases.containsAll(Set.of("cache_check","water","transit","heritage","village")),"Real phases observed: "+phases);
for(String lang:List.of("fr_fr","en_us"))c.runOnClient(m->{m.getLanguageManager().setSelected(lang);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());for(String phase:phases)check(!Component.translatable("sanctuary.preload."+phase).getString().startsWith("sanctuary."),"Localized phase "+lang+" "+phase);});
System.out.println("LOADING064_PASS phases="+phases);
}catch(Exception e){throw new AssertionError(e);}finally{observing.set(false);}
}
private record Evidence(com.google.gson.JsonElement core,PopulationHydrology.Plan.Snapshot water,PopulationLavaDeposit.Plan.Snapshot lava,long blocks,long writes,long protection) {}
private static Evidence evidence(SanctuaryChunkGenerator g) {
var root=g.source().islands().stream().filter(fr.koka.sanctuary.expansion.ExpansionIsland::origin).findFirst().orElseThrow();
var region=PopulationHydrologyRuntime.regionPlan(g.delegate(root),g.randomState(root),new IslandCapacity.Region(0,0));
// Reconstructed fluid indexes must agree with every retained geometric cell.
for(var cell:region.water().cells())check(region.water().cellsAt(cell.x(),cell.z()).contains(cell)&&region.water().cellsInChunk(cell.x()>>4,cell.z()>>4).contains(cell),"Hydrology indexes retain cells");
var writer=new Writer();
g.transitPlan21().modules().forEach(m->fr.koka.sanctuary.worldgen.transit.TransitRenderer21.render(m,writer));
g.ruinsPlan17().networks().forEach(n->n.rooms().forEach(r->fr.koka.sanctuary.worldgen.ruins.RuinsRenderer17.render(r,writer)));
g.heritagePlan19().sites().forEach(s->fr.koka.sanctuary.worldgen.heritage.HeritageRenderer19.render(s,writer));
g.heritagePlan19().segments().forEach(s->fr.koka.sanctuary.worldgen.heritage.HeritageRenderer19.renderSegment(s,writer));
if(g.mineralSanctuary23().chamber()!=null)fr.koka.sanctuary.worldgen.mineral.MineralSanctuaryRenderer23.render(g.mineralSanctuary23().chamber(),writer);
long protection=1;
for(int x=-352;x<=352;x+=16)for(int z=-352;z<=352;z+=16)for(int y=16;y<384;y+=8)protection=31*protection+(g.protectsCityDecoration(new BlockPos(x,y,z))?1:0);
return new Evidence(JsonParser.parseString(g.corePlanSignature()),region.water().snapshot(),region.lava().snapshot(),writer.hash,writer.writes,protection);
}
private static final class Writer implements fr.koka.sanctuary.worldgen.city.LostCityRenderer.BlockWriter {
long hash=0xcbf29ce484222325L,writes;
void add(long v){hash=(hash^v)*0x100000001b3L;}
public void set(int x,int y,int z,BlockState state){add(x);add(y);add(z);add(Block.getId(state));writes++;}
public void chest(int x,int y,int z,net.minecraft.resources.ResourceKey<net.minecraft.world.level.storage.loot.LootTable> table,long seed){add(x);add(y);add(z);add(table.identifier().toString().hashCode());add(seed);writes++;}
}
private static void cacheFailures() {
try {
Path root=Files.createTempDirectory("loading064-cache-");var cache=new DerivedPlanCache(root,"one");
check(cache.read("test",Fixture.class,x->x)==null,"Missing cache is a miss");cache.write("test",new Fixture(7));
check(cache.read("test",Fixture.class,x->x).value()==7,"Round trip");
var changed=new DerivedPlanCache(root,"two");check(changed.read("test",Fixture.class,x->x)==null,"Changed identity invalidates");
Files.createDirectories(changed.directory());Files.copy(cache.directory().resolve("test.json.gz"),changed.directory().resolve("test.json.gz"));
check(changed.read("test",Fixture.class,x->x)==null,"Even copied cache verifies embedded identity");
var file=cache.directory().resolve("test.json.gz");
com.google.gson.JsonObject envelope;
try(var in=new java.util.zip.GZIPInputStream(Files.newInputStream(file))){envelope=JsonParser.parseString(new String(in.readAllBytes(),java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject();}
envelope.addProperty("payload","{\"value\":8}");
try(var out=new java.util.zip.GZIPOutputStream(Files.newOutputStream(file))){out.write(envelope.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));}
check(cache.read("test",Fixture.class,x->x)==null,"Payload checksum verified before decoding");cache.write("test",new Fixture(7));
check(cache.read("test",Fixture.class,x->{throw new IllegalArgumentException("invalid geometry");})==null,"Validation failure falls back");
Files.writeString(cache.directory().resolve("test.json.gz"),"truncated");check(cache.read("test",Fixture.class,x->x)==null,"Truncated gzip falls back");
cache.write("test",new Fixture(9));check(cache.read("test",Fixture.class,x->x).value()==9,"Bad cache replaceable");
try(var files=Files.walk(root)){for(Path p:files.sorted(Comparator.reverseOrder()).toList())Files.delete(p);}
}catch(Exception e){throw new AssertionError(e);}
}
private static KeyEvent enter(){return new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String msg){if(!ok)throw new AssertionError(msg);}
}
@@ -0,0 +1,80 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.loading.PreloadProgress;
import java.util.concurrent.atomic.AtomicReference;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.screens.GenericMessageScreen;
import net.minecraft.client.gui.screens.LevelLoadingScreen;
import net.minecraft.client.multiplayer.LevelLoadTracker;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.progress.ChunkLoadStatusView;
import net.minecraft.server.level.progress.LevelLoadListener;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.resources.ResourceKey;
/** Render the actual mixins without creating a world or changing its loading lifecycle. */
public final class Loading068ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var previous=c.computeOnClient(m->m.gui.screen());
int scale=c.computeOnClient(m->m.options.guiScale().get());
String language=c.computeOnClient(m->m.getLanguageManager().getSelected());
var progress=progress();var old=progress.get();
var state=new PreloadProgress.State(null,"cache_read",System.nanoTime());
try {
progress.set(state);
c.runOnClient(m->{
m.options.guiScale().set(3);m.resizeGui();
m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
m.gui.setScreen(new GenericMessageScreen(Component.translatable("menu.preparingSpawn")) {
@Override public void tick() {} // No tick-based animation can pass this fixture.
});
});
for(int frame=0;frame<8;frame++) {
long until=System.nanoTime()+350_000_000L;
c.waitFor(m->System.nanoTime()>=until,1200);
c.takeScreenshot("loading068-animation-"+frame);
check(progress.get()==state,"Rendering must not invent or advance a server phase");
}
for(int gui:new int[]{2,4}) {
c.runOnClient(m->{m.options.guiScale().set(gui);m.resizeGui();});
c.waitTicks(3);c.takeScreenshot("loading068-fr-scale-"+gui);
}
c.runOnClient(m->{
m.options.guiScale().set(3);m.resizeGui();
m.getLanguageManager().setSelected("en_us");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
progress.set(new PreloadProgress.State(null,"water",state.started()));
});
c.waitTicks(3);c.takeScreenshot("loading068-en-water");
progress.set(null);
c.runOnClient(m->m.gui.setScreen(new GenericMessageScreen(Component.literal("Native message outside preparation"))));
c.waitTicks(3);c.takeScreenshot("loading068-native-message");
for(boolean grid:new boolean[]{false,true}) {
c.runOnClient(m->{
var tracker=new LevelLoadTracker();
tracker.start(LevelLoadListener.Stage.LOAD_INITIAL_CHUNKS,100);
tracker.update(LevelLoadListener.Stage.LOAD_INITIAL_CHUNKS,42,100);
if(grid)tracker.setServerChunkStatusView(new ChunkLoadStatusView() {
public void moveTo(ResourceKey<Level> level,ChunkPos pos) {}
public int radius(){return 11;}
public ChunkStatus get(int x,int z){return Math.abs(x)+Math.abs(z)<9?ChunkStatus.FULL:ChunkStatus.TERRAIN;}
});
m.gui.setScreen(new LevelLoadingScreen(tracker,LevelLoadingScreen.Reason.OTHER));
});
c.waitTicks(12);c.takeScreenshot("loading068-terrain-"+grid);
}
System.out.println("LOADING068_PASS: native FR/EN, GUI 2/3/4, eight frames without screen ticks, real phase unchanged, native chunk progress retained");
} finally {
progress.set(old);
c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(previous);});
}
}
@SuppressWarnings("unchecked")
private static AtomicReference<PreloadProgress.State> progress() {
try {var field=PreloadProgress.class.getDeclaredField("CURRENT");field.setAccessible(true);return (AtomicReference<PreloadProgress.State>)field.get(null);}
catch(ReflectiveOperationException e){throw new AssertionError(e);}
}
private static void check(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,125 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.cycle.*;
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.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.input.MouseButtonInfo;
import net.minecraft.client.gui.screens.advancements.*;
import fr.koka.sanctuary.mixin.client.NotificationAdvancementTabAccess;
import net.minecraft.resources.Identifier;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
/** Real native routes and server-confirmed actions, including while the integrated world is paused. */
public final class Menus083ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Menus083 "+UUID.randomUUID());s.setSeed("83");s.setAllowCommands(false);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.giveExperienceLevels(30);ProgressionService.send(p,"");});c.waitTicks(5);
server.runOnServer(s->{var p=player(s);p.getInventory().setItem(0,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.STONE));p.awardStat(net.minecraft.stats.Stats.ITEM_PICKED_UP.get(net.minecraft.world.item.Items.BREAD),3);p.awardStat(net.minecraft.stats.Stats.ENTITY_KILLED.get(net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getValue(net.minecraft.resources.Identifier.withDefaultNamespace("zombie"))),2);p.awardStat(net.minecraft.stats.Stats.JUMP,12);});
server.runOnServer(s->{var p=player(s);for(String id:List.of("husbandry/root","husbandry/plant_seed")){var a=s.getAdvancements().get(Identifier.withDefaultNamespace(id));for(String key:a.value().criteria().keySet())p.getAdvancements().award(a,key);}p.getAdvancements().award(s.getAdvancements().get(Identifier.withDefaultNamespace("husbandry/balanced_diet")),"rotten_flesh");});
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3)){
c.runOnClient(m->{m.getWindow().setWindowed(1280,800);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new PauseScreen(true));});c.waitTicks(3);
c.runOnClient(m->{var b=button(m.gui.screen(),"sanctuary.arena.title");check(b.getY()>0&&b.getY()+b.getHeight()<m.gui.screen().height,"Combat button in viewport");});
c.takeScreenshot("menus083-pause-"+locale+"-"+scale);
c.runOnClient(m->{check(find(m.gui.screen(),"gui.advancements")==null&&find(m.gui.screen(),"gui.stats")==null,"Grouped native pause pages");check(button(m.gui.screen(),"sanctuary.cycle.faction").getY()==button(m.gui.screen(),"sanctuary.arena.title").getY(),"Faction and battles share row");});
click(c,"sanctuary.progression.blocks");c.waitTicks(10);c.takeScreenshot("menus083-discovery-"+locale+"-"+scale);
click(c,"sanctuary.discovery.general");c.waitFor(m->hasText(m.gui.screen(),"sanctuary.discovery.count"),100);c.takeScreenshot("menus083-general-"+locale+"-"+scale);
click(c,"sanctuary.discovery.items");c.waitFor(m->hasText(m.gui.screen(),"sanctuary.discovery.count"),100);
c.runOnClient(m->{check(find(m.gui.screen(),"item.minecraft.bread")==null,"Unowned item hidden initially");check(!button(m.gui.screen(),"sanctuary.discovery.owned").active,"Catalogue required for historical items");button(m.gui.screen(),"block.minecraft.stone");});
c.takeScreenshot("menus083-items-"+locale+"-"+scale);
click(c,"sanctuary.discovery.mobs");c.waitFor(m->hasText(m.gui.screen(),"sanctuary.discovery.count"),100);
c.runOnClient(m->{button(m.gui.screen(),"entity.minecraft.zombie");check(find(m.gui.screen(),"entity.minecraft.creeper")==null,"Unknown mob hidden");});
click(c,"entity.minecraft.zombie");c.takeScreenshot("menus083-mob-detail-"+locale+"-"+scale);c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
click(c,"sanctuary.discovery.recipes");c.takeScreenshot("menus083-recipes-"+locale+"-"+scale);c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
c.runOnClient(m->check(m.gui.screen() instanceof PauseScreen,"Sibling discovery tabs return straight to pause"));
click(c,"sanctuary.progression.history");c.runOnClient(m->check(m.gui.screen() instanceof SanctuaryAtlasScreen,"World opens map directly"));c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
click(c,"sanctuary.progression.identity");c.runOnClient(m->check(m.gui.screen() instanceof ProgressionScreen,"Inhabitant opens personal page"));c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
click(c,"sanctuary.cycle.faction");c.waitFor(m->CycleClient.view!=null,100);c.waitTicks(5);c.takeScreenshot("menus083-faction-"+locale+"-"+scale);c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
click(c,"sanctuary.arena.title");c.waitFor(m->m.gui.screen() instanceof ArenaScreen&&ArenaClient.view!=null,100);
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen() instanceof PauseScreen,"Arena returns to pause"));
c.runOnClient(m->{ArenaClient.open(m.gui.screen());m.gui.screen().onClose();});c.waitTicks(8);c.runOnClient(m->check(m.gui.screen() instanceof PauseScreen,"Late arena response does not reopen it"));
click(c,"sanctuary.progression.progress");c.runOnClient(m->{check(find(m.gui.screen(),"sanctuary.arena.title")==null,"No combat in progression");check(find(m.gui.screen(),"sanctuary.cycle.faction")==null,"No faction in progression");for(String tab:List.of("cycle","log"))button(m.gui.screen(),"sanctuary.cycle."+tab);button(m.gui.screen(),"gui.advancements");});
c.takeScreenshot("menus083-progress-"+locale+"-"+scale);
c.runOnClient(m->{var screen=(ProgressionScreen)m.gui.screen();check(button(screen,"sanctuary.cycle.cycle").getY()>=screen.contentTop+12,"XP has separate line");check(button(screen,"gui.advancements").getX()<screen.width/2,"Advancements above left skills");});
click(c,"gui.advancements");trackDiet(c);c.runOnClient(m->check(m.gui.screen() instanceof net.minecraft.client.gui.screens.advancements.AdvancementsScreen,"Full native advancements"));c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen() instanceof ProgressionScreen,"Advancements returns to progression"));c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
}
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());});
click(c,"sanctuary.progression.blocks");click(c,"sanctuary.discovery.recipes");click(c,"sanctuary.recipes.buy");c.waitFor(m->RecipeClient.knowledge!=null&&RecipeClient.knowledge.learned(),100);
click(c,"sanctuary.discovery.items");c.waitFor(m->hasText(m.gui.screen(),"sanctuary.discovery.count"),100);click(c,"sanctuary.discovery.owned");
c.runOnClient(m->{button(m.gui.screen(),"item.minecraft.bread");check(find(m.gui.screen(),"item.minecraft.diamond")==null,"Catalogue still hides unknown items");});
click(c,"item.minecraft.bread");c.takeScreenshot("menus083-item-detail");c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
click(c,"sanctuary.progression.progress");click(c,"sanctuary.cycle.cycle");c.waitFor(m->CycleClient.view!=null,100);c.waitTicks(5);
c.runOnClient(m->check(!button(m.gui.screen(),"sanctuary.cycle.ngp").active,"Incomplete prestige disabled"));click(c,"sanctuary.cycle.cycle_progress");
c.runOnClient(m->check(m.gui.screen() instanceof ProgressionScreen,"Prestige link returns to skills"));
c.runOnClient(m->widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.active&&b.visible&&b.getMessage().getString().equals("+")).findFirst().orElseThrow().onPress(enter()));
c.waitFor(m->ProgressionClient.progress.vitality()==1,100);
c.runOnClient(m->check(widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).noneMatch(b->b.visible&&b.getMessage().getString().equals("")),"Refund invisible in normal play"));
toggleOperator(c);c.waitFor(m->ProgressionClient.operator,150);
c.runOnClient(m->m.gui.setScreen(new ProgressionScreen(new PauseScreen(true))));c.waitTicks(4);glyph(c,"");
c.waitFor(m->ProgressionClient.progress.vitality()==0,100);c.runOnClient(m->check(m.player.experienceLevel==26,"Operator refund returns last purchase cost"));
c.takeScreenshot("menus083-refund");glyph(c,"+");c.waitFor(m->ProgressionClient.progress.vitality()==1,100);
toggleOperator(c);c.waitFor(m->!ProgressionClient.operator,150);c.runOnClient(m->m.gui.setScreen(new ProgressionScreen(new PauseScreen(true))));c.waitTicks(4);
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);click(c,"sanctuary.cycle.faction");c.waitTicks(8);
c.runOnClient(m->{check(!button(m.gui.screen(),"sanctuary.cycle.create").active,"Blank faction name disabled");edit(m.gui.screen()).setValue("A");check(!button(m.gui.screen(),"sanctuary.cycle.create").active,"Short name disabled");edit(m.gui.screen()).setValue("Navigateurs");});
click(c,"sanctuary.cycle.create");click(c,"sanctuary.cycle.cancel");c.runOnClient(m->check(CycleClient.view.faction()==null,"Cancel does not found"));
click(c,"sanctuary.cycle.create");click(c,"sanctuary.cycle.confirm");c.waitFor(m->CycleClient.view.faction()!=null,100);
c.runOnClient(m->{check(CycleClient.view.faction().capacity()==2,"Two founding places");check(!button(m.gui.screen(),"sanctuary.cycle.invite").active,"Blank invitation disabled");check(m.player.experienceLevel==15,"Catalogue, skill and faction paid exactly once");});
click(c,"sanctuary.cycle.faction_log");c.runOnClient(m->{check(hasText(m.gui.screen(),"sanctuary.cycle.log_line"),"Faction journal has founding event");check(!hasText(m.gui.screen(),"sanctuary.progression.history_purchase"),"No skill purchases in faction journal");check(find(m.gui.screen(),"sanctuary.cycle.cycle")==null,"No prestige in faction navigation");});
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);click(c,"sanctuary.progression.progress");click(c,"sanctuary.cycle.log");c.waitTicks(5);c.runOnClient(m->{check(hasText(m.gui.screen(),"sanctuary.cycle.log_line"),"Faction event visible");check(hasText(m.gui.screen(),"sanctuary.progression.history_purchase"),"Purchase visible in history");check(hasText(m.gui.screen(),"sanctuary.progression.history_refund"),"Refund visible in history");});c.takeScreenshot("menus083-history-fr");
click(c,"sanctuary.cycle.refresh");c.waitTicks(8);c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
server.runOnServer(s->{var p=player(s);var r=ProgressEffects.get(p).buy("names",4);for(String skill:ProgressRecord.SKILLS)while(r.rank(skill)<ProgressRecord.cap(skill))r=r.buy(skill,1);p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");});c.waitFor(m->ProgressionClient.progress.complete(),100);
click(c,"sanctuary.cycle.cycle");c.waitTicks(8);click(c,"sanctuary.cycle.ngp");click(c,"sanctuary.cycle.cancel");click(c,"sanctuary.cycle.ngp");click(c,"sanctuary.cycle.confirm");
c.waitFor(m->CycleClient.view.prestige()==1&&ProgressionClient.progress.cycle()==1,100);
c.runOnClient(m->{check(ProgressionClient.progress.inventoryRows()==1&&ProgressionClient.progress.names(),"Prestige resets skills and keeps ability");check(CycleClient.view.faction().name().equals("Navigateurs")&&CycleClient.view.charges()==1,"Faction and prestige charge preserved");check(!button(m.gui.screen(),"sanctuary.cycle.ngp").active,"Cannot repeat prestige");});
click(c,"sanctuary.cycle.log");c.waitTicks(5);c.takeScreenshot("menus083-prestige-history");c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(5);
System.out.println("MENUS083_PASS: FR/EN GUI 2/3, six pause destinations, independent categories, native statistics, knowledge gates, advancements, buy/refund, faction journal, history, prestige, late arena close");
}finally{intro.set(false);}
}
private static void glyph(ClientGameTestContext c,String glyph){c.runOnClient(m->widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.active&&b.visible&&b.getMessage().getString().equals(glyph)).findFirst().orElseThrow().onPress(enter()));c.waitTicks(5);}
private static void toggleOperator(ClientGameTestContext c){
c.runOnClient(m->{m.getLanguageManager().setSelected("en_us");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new WorldOptionsScreen(m.gui.screen(),m.level));});c.waitTicks(3);
for(String prefix:List.of("Allow Commands","Apply Changes"))c.runOnClient(m->{var w=widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(b->b.getMessage().getString().startsWith(prefix)).findFirst().orElseThrow();check(w.visible&&w.active,"Operator toggle accessible");if(w instanceof Button b)b.onPress(enter());else if(w instanceof CycleButton<?> b)b.onPress(enter());});
c.waitTicks(5);
}
private static void trackDiet(ClientGameTestContext c){
var id=Identifier.withDefaultNamespace("husbandry/balanced_diet");
c.runOnClient(m->{var adv=m.getConnection().getAdvancements();var root=adv.progress().keySet().stream().filter(a->a.id().toString().equals("minecraft:husbandry/root")).findFirst().orElseThrow();adv.setSelectedTab(root,true);});c.waitTicks(3);
c.runOnClient(m->{var screen=(AdvancementsScreen)m.gui.screen();var tab=(AdvancementTab)field(screen,"selectedTab");var access=(NotificationAdvancementTabAccess)tab;var w=access.sanctuary$widgets().values().stream().filter(a->a.getAdvancement().id().equals(id)).findFirst().orElseThrow();tab.scroll(100-w.getX()-access.sanctuary$scrollX(),45-w.getY()-access.sanctuary$scrollY());});c.waitTicks(2);
c.runOnClient(m->{var screen=(AdvancementsScreen)m.gui.screen();var access=(NotificationAdvancementTabAccess)field(screen,"selectedTab");var w=access.sanctuary$widgets().values().stream().filter(a->a.getAdvancement().id().equals(id)).findFirst().orElseThrow();var event=new MouseButtonEvent((int)field(screen,"leftPos")+9+Math.floor(access.sanctuary$scrollX())+w.getX()+12,(int)field(screen,"topPos")+18+Math.floor(access.sanctuary$scrollY())+w.getY()+12,new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0));boolean before=NotificationClient.isPinned(id);screen.mouseClicked(event,false);screen.mouseReleased(event);check(NotificationClient.isPinned(id)!=before,"Native click toggles advancement tracking through new route");});
}
private static Object field(AdvancementsScreen screen,String name){try{var f=AdvancementsScreen.class.getDeclaredField(name);f.setAccessible(true);return f.get(screen);}catch(Exception e){throw new AssertionError(e);}}
private static java.util.stream.Stream<net.minecraft.client.gui.components.events.GuiEventListener> widgets(net.minecraft.client.gui.components.events.GuiEventListener e){
return java.util.stream.Stream.concat(java.util.stream.Stream.of(e),e instanceof net.minecraft.client.gui.components.events.ContainerEventHandler h?h.children().stream().flatMap(Menus083ClientChecks::widgets):java.util.stream.Stream.empty());
}
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 Button find(Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElse(null);}
private static Button button(Screen s,String key){return Objects.requireNonNull(find(s,key),"Missing button "+key+" on "+s.getClass().getSimpleName());}
private static void press(Screen s,String key){var b=button(s,key);check(b.active,"Disabled "+key);b.onPress(enter());}
private static void click(ClientGameTestContext c,String key){c.runOnClient(m->press(m.gui.screen(),key));c.waitTicks(5);}
private static EditBox edit(Screen s){return widgets(s).filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow();}
private static boolean hasText(Screen s,String key){return widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w->contains(w.getMessage(),key));}
private static boolean contains(Component c,String key){if(c.getContents() instanceof TranslatableContents t&&t.getKey().equals(key))return true;return c.getSiblings().stream().anyMatch(s->contains(s,key));}
private static void check(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,206 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import fr.koka.sanctuary.mixin.MultiblockFurnaceAccess;
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.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.*;
import net.minecraft.world.*;
import net.minecraft.world.entity.player.*;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.phys.*;
public final class Multiblocks084ClientChecks implements FabricClientGameTest {
private static final BlockPos BARREL=new BlockPos(15,80,0),FURNACE=new BlockPos(15,80,7);
private static MultiblockData.Machine cask,oven;
private static io.netty.channel.embedded.EmbeddedChannel guestChannel;
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Multiblocks084 "+UUID.randomUUID());s.setSeed("84");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;var l=p.level();
var progress=ProgressRecord.INITIAL;var config=ProgressionService.session(s).config;for(int i=0;i<5;i++)progress=progress.buy("inventory",progress.cost("inventory",config));p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);ProgressionService.send(p,"");
for(int x=10;x<23;x++)for(int z=-4;z<14;z++)l.setBlockAndUpdate(new BlockPos(x,79,z),Blocks.STONE.defaultBlockState());
p.teleportTo(l,13.5,80,1.5,Set.of(),-90,0,true);
cask=structure(l,BARREL,false);oven=structure(l,FURNACE,true);
check(MultiblockData.get(l).at(BARREL)==null,"No automatic assembly");
var stock=new MultiblockContainer(l,cask);stock.setItem(0,new ItemStack(Items.STONE,64));
var diamond=new ItemStack(Items.DIAMOND,17);diamond.set(DataComponents.CUSTOM_NAME,Component.literal("Souvenir 084"));stock.setItem(728,diamond);
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Multiblocks.WRENCH));
wrench(p,BARREL);check(MultiblockData.get(l).at(BARREL)==null,"First click previews only");wrench(p,BARREL);
cask=MultiblockData.get(l).at(BARREL);check(cask!=null,"Second click assembles");
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.HOPPER));p.setShiftKeyDown(true);
check(UseBlockCallback.EVENT.invoker().interact(p,l,InteractionHand.MAIN_HAND,new BlockHitResult(Vec3.atCenterOf(BARREL),Direction.WEST,BARREL,false))==InteractionResult.PASS,"Sneaking permits attaching blocks to machine");
p.setShiftKeyDown(false);p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Multiblocks.WRENCH));
check(!MultiblockData.get(l).add(new MultiblockData.Machine(UUID.randomUUID(),BARREL,false,Direction.NORTH)),"Overlap rejected");
check(stock.getItem(728).getCount()==17,"Existing component data retained");
var data=MultiblockData.get(l);var json=MultiblockData.CODEC.encodeStart(JsonOps.INSTANCE,data).getOrThrow();var reloaded=MultiblockData.CODEC.parse(JsonOps.INSTANCE,json).getOrThrow();
check(reloaded.at(BARREL.offset(2,2,2)).id().equals(cask.id()),"Persistence reconstructs all members across chunks");
try{
var folder=java.nio.file.Files.createTempDirectory("sanctuary-multiblocks084-");
try(var disk=new net.minecraft.world.level.storage.SavedDataStorage(folder,s.getFixerUpper(),l.registryAccess())){var record=MultiblockData.read(disk);record.add(cask);disk.saveAndJoin();}
try(var disk=new net.minecraft.world.level.storage.SavedDataStorage(folder,s.getFixerUpper(),l.registryAccess())){check(MultiblockData.read(disk).at(BARREL.offset(2,2,2)).id().equals(cask.id()),"Actual SavedData file reload retains assembly");}
try(var files=java.nio.file.Files.walk(folder)){for(var file:files.sorted(java.util.Comparator.reverseOrder()).toList())java.nio.file.Files.delete(file);}
}catch(java.io.IOException error){throw new AssertionError(error);}
json.getAsJsonObject().addProperty("schema",99);check(MultiblockData.CODEC.parse(JsonOps.INSTANCE,json).error().isPresent(),"Unknown schema rejected");
var common=HopperBlockEntity.getContainerAt(l,BARREL);check(common.getContainerSize()==729,"Hopper sees whole cask");
check(HopperBlockEntity.addItem(null,common,new ItemStack(Items.COBBLESTONE,64),Direction.UP).isEmpty(),"Native hopper insertion into common cask");
l.setBlockAndUpdate(BARREL.below(),Blocks.HOPPER.defaultBlockState());var hopper=(HopperBlockEntity)l.getBlockEntity(BARREL.below());
check(HopperBlockEntity.suckInItems(l,hopper)&&hopper.getItem(0).is(Items.STONE),"Native hopper extracts from common cask");
l.setBlockAndUpdate(BARREL.below(),Blocks.STONE.defaultBlockState());
var remote=guest(p);remote.setPos(13.5,80,2.5);Multiblocks.open(p,cask,BARREL,13,"",0);Multiblocks.open(remote,cask,BARREL,13,"",0);
var a=(MultiblockMenu)p.containerMenu;var b=(MultiblockMenu)remote.containerMenu;
int overflow=a.slots.size()-1;check(overflow>=90,"Six-row inventory includes overflow slots");a.getSlot(overflow).set(new ItemStack(Items.STICK,23));check(!a.quickMoveStack(p,overflow).isEmpty()&&a.getSlot(overflow).getItem().isEmpty(),"Shift deposit from sixth inventory row reaches whole cask");
check(a.view.indices().length==27&&a.getSlot(26).getItem().getCount()==17,"Last page has 27 real cells");
a.getSlot(26).remove(7);check(b.getSlot(26).getItem().getCount()==10,"Second viewer sees same physical stack");
common.removeItem(728,3);check(a.getSlot(26).getItem().getCount()==7,"Hopper and players share stock");
var result=b.quickMoveStack(remote,26);check(result.getCount()==7&&stock.getItem(728).isEmpty(),"Shift take exactly once");
for(int i=0;i<remote.getInventory().getContainerSize();i++)remote.getInventory().setItem(i,new ItemStack(Items.DIRT,64));
stock.setItem(728,new ItemStack(Items.DIAMOND));check(b.quickMoveStack(remote,26).isEmpty()&&stock.getItem(728).getCount()==1,"Full player inventory leaves source intact");stock.setItem(728,ItemStack.EMPTY);
check(a.quickMoveStack(p,26).isEmpty(),"Other viewer cannot duplicate consumed stack");
var restored=new ItemStack(Items.DIAMOND,17);restored.set(DataComponents.CUSTOM_NAME,Component.literal("Souvenir 084"));stock.setItem(728,restored);
guestChannel.outboundMessages().clear();int oldRemoteId=remote.containerMenu.containerId;
((MultiblockMenu)remote.containerMenu).change(remote,0,"",0);guestChannel.runPendingTasks();
check(remote.containerMenu.containerId!=oldRemoteId,"Navigation still allocates a fresh menu ID");
check(guestChannel.outboundMessages().stream().noneMatch(packet->packet instanceof net.minecraft.network.protocol.game.ClientboundContainerClosePacket),"Navigation sends no intermediate close packet");
remote.closeContainer();guestChannel.runPendingTasks();
check(guestChannel.outboundMessages().stream().anyMatch(packet->packet instanceof net.minecraft.network.protocol.game.ClientboundContainerClosePacket),"Actual close still sends close packet");
remote.openMenu(new SimpleMenuProvider((id,inv,player)->ChestMenu.threeRows(id,inv),Component.literal("Unrelated")));
Multiblocks.closeViewers(l,cask);check(remote.containerMenu!=remote.inventoryMenu,"Machine changes leave unrelated nearby menus open");
remote.closeContainer();remote.discard();
Multiblocks.open(p,cask,BARREL,0,"",0);
// Same native block-entity serialization used when a world unloads/reloads.
var last=l.getBlockEntity(BARREL.offset(2,2,2));var tag=last.saveWithFullMetadata(l.registryAccess());
var restoredBe=(BarrelBlockEntity)BlockEntity.loadStatic(last.getBlockPos(),last.getBlockState(),tag,l.registryAccess());
check(ItemStack.matches(restoredBe.getItem(26),restored),"Native persistence retains custom stack components");
});
c.waitFor(m->m.gui.screen() instanceof MultiblockScreen,200);c.waitTicks(5);
for(String language:List.of("fr_fr","en_us"))for(int scale:List.of(2,3)){
c.runOnClient(m->{m.getWindow().setWindowed(1280,800);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());});c.waitTicks(3);c.takeScreenshot("multiblocks084-cask-"+language+"-"+scale);
c.runOnClient(m->{var screen=m.gui.screen();for(var child:screen.children())if(child instanceof AbstractWidget w)check(w.getX()>=0&&w.getX()+w.getWidth()<=screen.width&&w.getY()>=0&&w.getY()+w.getHeight()<=screen.height,"Controls fit GUI scale "+scale);});
}
// Regression beta.088: click several pages at the same physical cursor position.
double[] cursor=hoverButton(c,">");
for(int page=1;page<=3;page++){int expected=page;clickMouse(c);c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.page()==expected,200);c.waitTicks(4);assertCursor(c,cursor,"Next page "+page);}
cursor=hoverButton(c,"<");
for(int page=2;page>=1;page--){int expected=page;clickMouse(c);c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.page()==expected,200);c.waitTicks(4);assertCursor(c,cursor,"Previous page "+page);}
c.runOnClient(m->{m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.screen().children().stream().filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow().setValue("Roche");});
cursor=hoverButton(c,c.computeOnClient(m->Multiblocks.text("search").getString()));clickMouse(c);
c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.query().equals("Roche"),200);c.waitTicks(4);
assertCursor(c,cursor,"Search");
c.runOnClient(m->{var menu=(MultiblockMenu)m.player.containerMenu;check(menu.view.indices().length>0&&menu.getSlot(0).getItem().is(Items.STONE),"Localized French search maps to server-owned items");});
int previous=c.computeOnClient(m->m.player.containerMenu.containerId);
c.runOnClient(m->ClientPlayNetworking.send(new MultiblockMenu.Change(previous,0,"Souvenir",0)));
c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.containerId!=previous&&menu.view.query().equals("Souvenir"),200);
c.waitTicks(3);c.runOnClient(m->{var menu=(MultiblockMenu)m.player.containerMenu;check(menu.view.indices().length==1&&menu.getSlot(0).getItem().is(Items.DIAMOND),"Native filtered contents synchronized");});c.takeScreenshot("multiblocks084-search");
c.runOnClient(m->ClientPlayNetworking.send(new MultiblockMenu.Change(previous,13,"",0)));c.waitTicks(4);
c.runOnClient(m->check(((MultiblockMenu)m.player.containerMenu).view.query().equals("Souvenir"),"Stale page request rejected"));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var menu=(MultiblockMenu)p.containerMenu;int old=menu.containerId;
menu.setCarried(new ItemStack(Items.STICK));menu.change(p,1,"",0);check(p.containerMenu.containerId==old,"Cursor blocks page change");menu.setCarried(ItemStack.EMPTY);
var l=p.level();var before=new MultiblockContainer(l,cask).getItem(728).copy();Multiblocks.dissolve(l,cask);
check(((BarrelBlockEntity)l.getBlockEntity(BARREL.offset(2,2,2))).getItem(26).equals(before)||ItemStack.matches(((BarrelBlockEntity)l.getBlockEntity(BARREL.offset(2,2,2))).getItem(26),before),"Disassembly preserves named stack");
check(Multiblocks.assemble(p,cask),"Can reassemble current stock");
l.destroyBlock(BARREL,true);check(!Multiblocks.ready(l,cask),"Broken component invalidates menus immediately");
check(new MultiblockContainer(l,cask).getItem(728).getCount()==17,"Breaking other cell preserves remainder");
check(MultiblockData.get(l).at(BARREL.offset(2,2,2))==null,"Break dissolves membership immediately");
explosionCheck(p);
p.teleportTo(l,13.5,80,8.5,Set.of(),-90,0,true);check(Multiblocks.assemble(p,oven),"Assemble furnace");
cookChecks(p);Multiblocks.open(p,oven,FURNACE,0,"",2);
});
c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.furnace(),200);c.waitTicks(5);c.takeScreenshot("multiblocks084-furnace");
for(int section=0;section<3;section++){int expected=section;String key=new String[]{"inputs","fuel","outputs"}[section];cursor=hoverButton(c,c.computeOnClient(m->Multiblocks.text(key).getString()));clickMouse(c);c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.section()==expected,200);c.waitTicks(4);assertCursor(c,cursor,"Furnace section "+section);}
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitForScreen(null);
server.waitFor(s->{var p=s.getPlayerList().getPlayers().getFirst();return p.containerMenu==p.inventoryMenu;},200);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var l=p.level();var stock=new MultiblockContainer(l,oven);int before=items(stock);Multiblocks.dissolve(l,oven);check(items(stock)==before,"Furnace disassembly preserves current fuel/outputs");
// An absent distant chunk suspends membership, without creating or loading that chunk.
var far=new MultiblockData.Machine(UUID.randomUUID(),new BlockPos(300000,80,300000),true,Direction.NORTH);check(!l.hasChunkAt(far.origin()),"Fixture starts unloaded");MultiblockData.get(l).add(far);check(Multiblocks.state(l,far)==0&&!l.hasChunkAt(far.origin()),"Validation never loads remote chunks");MultiblockData.get(l).remove(far);
p.closeContainer();System.out.println("MULTIBLOCKS084_PASS: explicit preview/assembly, cross-chunk membership, schema, 729 slots, 2 server players, native hopper view, pages/search/new IDs/cursor, stationary mouse paging and furnace tabs, Escape closure, component preservation, break, shared native smelting/fuel/remainders/XP, no forced chunk loads, FR/EN GUI2/3");
});
}finally{intro.set(false);}
}
private static double[] hoverButton(ClientGameTestContext c,String text){
return c.computeOnClient(m->{var button=m.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(text)).findFirst().orElseThrow();var w=m.getWindow();
double x=button.getX()+button.getWidth()/2.0,y=button.getY()+button.getHeight()/2.0;
double sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();
org.lwjgl.sdl.SDLMouse.SDL_WarpMouseInWindow(w.handle(),(float)(x*sx),(float)(y*sy));
m.mouseHandler.onMove(w.handle(),x*sx,y*sy,(x-m.mouseHandler.getScaledXPos(w))*sx,(y-m.mouseHandler.getScaledYPos(w))*sy);m.mouseHandler.handleAccumulatedMovement();return new double[]{x,y};});
}
private static void clickMouse(ClientGameTestContext c){
c.runOnClient(m->{var button=new net.minecraft.client.input.MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0);m.mouseHandler.onButton(m.getWindow().handle(),button,1);m.mouseHandler.onButton(m.getWindow().handle(),button,0);});
}
private static void assertCursor(ClientGameTestContext c,double[] cursor,String step){
c.runOnClient(m->{check(Math.abs(m.mouseHandler.getScaledXPos(m.getWindow())-cursor[0])<1&&Math.abs(m.mouseHandler.getScaledYPos(m.getWindow())-cursor[1])<1,step+" keeps mouse over its button: expected "+Arrays.toString(cursor)+", got "+m.mouseHandler.getScaledXPos(m.getWindow())+","+m.mouseHandler.getScaledYPos(m.getWindow()));});
}
private static void cookChecks(ServerPlayer p){
var l=p.level();var stock=new MultiblockContainer(l,oven);
stock.setItem(0,new ItemStack(Items.RAW_IRON,8));stock.setItem(3,new ItemStack(Items.RAW_GOLD,8));stock.setItem(79,new ItemStack(Items.COAL,2));
var nativeBe=(AbstractFurnaceBlockEntity)stock.cell(0);
AbstractFurnaceBlockEntity.serverTick(l,nativeBe.getBlockPos(),nativeBe.getBlockState(),nativeBe);
check(((MultiblockFurnaceAccess)nativeBe).sanctuary$progress()==0,"Native tick cannot double-cook an assembled lane");
for(int tick=0;tick<201;tick++)MultiblockCooking.tick(l,oven);
check(stock.getItem(2).is(Items.IRON_INGOT)&&stock.getItem(5).is(Items.GOLD_INGOT),"Two lanes smelt with distant common fuel");
int coal=0;for(int i=1;i<81;i+=3)if(stock.getItem(i).is(Items.COAL))coal+=stock.getItem(i).getCount();check(coal==1,"Only one shared coal consumed");
int heat=0;for(int i=0;i<81;i+=3)heat+=((MultiblockFurnaceAccess)stock.cell(i)).sanctuary$heat();check(heat>=1180&&heat<=1210,"Heat cost reflects both lanes: "+heat);
var physical=(AbstractFurnaceBlockEntity)stock.cell(0);var saved=physical.saveWithFullMetadata(l.registryAccess());
var restored=BlockEntity.loadStatic(physical.getBlockPos(),physical.getBlockState(),saved,l.registryAccess());
check(((MultiblockFurnaceAccess)restored).sanctuary$heat()==((MultiblockFurnaceAccess)physical).sanctuary$heat(),"Native save preserves remaining shared heat");
check(((MultiblockFurnaceAccess)restored).sanctuary$progress()==((MultiblockFurnaceAccess)physical).sanctuary$progress(),"Native save preserves work in progress");
Multiblocks.open(p,oven,FURNACE,0,"",2);var menu=(MultiblockMenu)p.containerMenu;check(!menu.getSlot(0).mayPlace(new ItemStack(Items.STONE)),"Outputs reject deposits");check(!menu.quickMoveStack(p,0).isEmpty(),"Native result slot awards cooking XP/recipes");
stock.clearContent();for(int i=0;i<81;i+=3){var a=(MultiblockFurnaceAccess)stock.cell(i);a.sanctuary$heat(0);a.sanctuary$progress(0);}
stock.setItem(0,new ItemStack(Items.WET_SPONGE));stock.setItem(79,new ItemStack(Items.LAVA_BUCKET));
for(int tick=0;tick<205;tick++)MultiblockCooking.tick(l,oven);
check(stock.getItem(2).is(Items.SPONGE)&&stock.getItem(1).is(Items.WATER_BUCKET),"Native lava remainder and wet sponge interaction preserved");
stock.clearContent();for(int i=0;i<81;i+=3){var a=(MultiblockFurnaceAccess)stock.cell(i);a.sanctuary$heat(0);a.sanctuary$progress(0);}
stock.setItem(0,new ItemStack(Items.RAW_IRON));stock.setItem(2,new ItemStack(Items.IRON_INGOT,64));stock.setItem(79,new ItemStack(Items.COAL));
for(int tick=0;tick<30;tick++)MultiblockCooking.tick(l,oven);check(stock.getItem(79).getCount()==1&&stock.getItem(0).getCount()==1,"Full output never consumes new fuel or ingredient");
// One coal cannot finish 27 simultaneous recipes, but adding it in stages must not erase paid work.
stock.clearContent();for(int i=0;i<81;i+=3){var a=(MultiblockFurnaceAccess)stock.cell(i);a.sanctuary$heat(0);a.sanctuary$progress(0);stock.setItem(i,new ItemStack(Items.RAW_IRON));}
stock.setItem(79,new ItemStack(Items.COAL));for(int tick=0;tick<90;tick++)MultiblockCooking.tick(l,oven);
int partial=((MultiblockFurnaceAccess)stock.cell(0)).sanctuary$progress();check(partial>0&&partial<200,"Batch pauses midway when fuel runs out");
for(int tick=0;tick<30;tick++)MultiblockCooking.tick(l,oven);
check(((MultiblockFurnaceAccess)stock.cell(0)).sanctuary$progress()==partial,"Starved batch retains paid work");
stock.setItem(79,new ItemStack(Items.COAL,4));for(int tick=0;tick<205;tick++)MultiblockCooking.tick(l,oven);
int produced=0;for(int i=2;i<81;i+=3)produced+=stock.getItem(i).getCount();check(produced==27,"Refuelling completes all 27 lanes once: "+produced);
var hopper=(WorldlyContainer)HopperBlockEntity.getContainerAt(l,FURNACE);check(hopper.getSlotsForFace(Direction.UP).length==27&&hopper.getSlotsForFace(Direction.DOWN).length==54,"Native sided hopper slots aggregated");
}
private static void explosionCheck(ServerPlayer p){
var l=p.level();var origin=new BlockPos(48,80,0);var m=structure(l,origin,false);
check(Multiblocks.assemble(p,m),"Explosion fixture assembled");var stock=new MultiblockContainer(l,m);stock.setItem(0,new ItemStack(Items.DIAMOND,23));
l.explode(null,48.5,80.5,.5,4,false,net.minecraft.world.level.Level.ExplosionInteraction.TNT);
check(MultiblockData.get(l).at(origin)==null,"Explosion dissolves machine");
int loose=l.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,new AABB(origin).inflate(12)).stream().filter(e->e.getItem().is(Items.DIAMOND)).mapToInt(e->e.getItem().getCount()).sum();
int stored=0;for(var pos:m.members())if(l.getBlockEntity(pos) instanceof BarrelBlockEntity be)for(int i=0;i<27;i++)if(be.getItem(i).is(Items.DIAMOND))stored+=be.getItem(i).getCount();
check(loose+stored==23,"Explosion conserves container contents exactly once: "+loose+"/"+stored);
}
private static MultiblockData.Machine structure(ServerLevel l,BlockPos origin,boolean furnace){var m=new MultiblockData.Machine(UUID.randomUUID(),origin,furnace,Direction.NORTH);for(var pos:m.members())l.setBlockAndUpdate(pos,(furnace?Blocks.FURNACE:Blocks.BARREL).defaultBlockState());return m;}
private static int items(Container c){int n=0;for(int i=0;i<c.getContainerSize();i++)n+=c.getItem(i).getCount();return n;}
private static void wrench(ServerPlayer p,BlockPos pos){UseBlockCallback.EVENT.invoker().interact(p,p.level(),InteractionHand.MAIN_HAND,new BlockHitResult(Vec3.atCenterOf(pos),Direction.WEST,pos,false));}
private static ServerPlayer guest(ServerPlayer host){var s=host.level().getServer();var profile=new com.mojang.authlib.GameProfile(UUID.randomUUID(),"Visitor084");var p=new ServerPlayer(s,host.level(),profile,ClientInformation.createDefault());var connection=new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);guestChannel=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.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);p.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());host.level().addNewPlayer(p);return p;}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,93 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.cycle.*;
import fr.koka.sanctuary.familiar.*;
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.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
/** Real native routes and server-confirmed actions, including while the integrated world is paused. */
public final class Navigation076ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Navigation076 "+UUID.randomUUID());s.setSeed("76");s.setAllowCommands(false);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.giveExperienceLevels(30);ProgressionService.send(p,"");});c.waitTicks(5);
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3)){
c.runOnClient(m->{m.getWindow().setWindowed(1280,800);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new PauseScreen(true));});c.waitTicks(3);
c.runOnClient(m->{var b=button(m.gui.screen(),"sanctuary.arena.title");check(b.getY()>0&&b.getY()+b.getHeight()<m.gui.screen().height,"Combat button in viewport");});
c.takeScreenshot("navigation076-pause-"+locale+"-"+scale);
click(c,"sanctuary.arena.title");c.waitFor(m->m.gui.screen() instanceof ArenaScreen&&ArenaClient.view!=null,100);
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen() instanceof PauseScreen,"Arena returns to pause"));
c.runOnClient(m->{ArenaClient.open(m.gui.screen());m.gui.screen().onClose();});c.waitTicks(8);c.runOnClient(m->check(m.gui.screen() instanceof PauseScreen,"Late arena response does not reopen it"));
click(c,"sanctuary.progression.progress");c.runOnClient(m->{check(find(m.gui.screen(),"sanctuary.arena.title")==null,"No combat in progression");for(String tab:List.of("cycle","faction","log"))button(m.gui.screen(),"sanctuary.cycle."+tab);});
c.takeScreenshot("navigation076-progress-"+locale+"-"+scale);c.getInput().pressKey(InputConstants.KEY_ESCAPE);
}
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());});
click(c,"sanctuary.progression.progress");click(c,"sanctuary.cycle.cycle");c.waitFor(m->CycleClient.view!=null,100);c.waitTicks(5);
c.runOnClient(m->check(!button(m.gui.screen(),"sanctuary.cycle.ngp").active,"Incomplete prestige disabled"));click(c,"sanctuary.cycle.cycle_progress");
c.runOnClient(m->check(m.gui.screen() instanceof ProgressionScreen,"Prestige link returns to skills"));
c.runOnClient(m->widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.active&&b.visible&&b.getMessage().getString().equals("+")).findFirst().orElseThrow().onPress(enter()));
c.waitFor(m->ProgressionClient.progress.vitality()==1,100);
click(c,"sanctuary.cycle.faction");c.waitTicks(8);
c.runOnClient(m->{check(!button(m.gui.screen(),"sanctuary.cycle.create").active,"Blank faction name disabled");edit(m.gui.screen()).setValue("A");check(!button(m.gui.screen(),"sanctuary.cycle.create").active,"Short name disabled");edit(m.gui.screen()).setValue("Navigateurs");});
click(c,"sanctuary.cycle.create");click(c,"sanctuary.cycle.cancel");c.runOnClient(m->check(CycleClient.view.faction()==null,"Cancel does not found"));
click(c,"sanctuary.cycle.create");click(c,"sanctuary.cycle.confirm");c.waitFor(m->CycleClient.view.faction()!=null,100);
c.runOnClient(m->{check(CycleClient.view.faction().capacity()==2,"Two founding places");check(!button(m.gui.screen(),"sanctuary.cycle.invite").active,"Blank invitation disabled");check(m.player.experienceLevel==19,"Skill plus faction paid exactly once");});
click(c,"sanctuary.cycle.log");c.waitTicks(5);c.runOnClient(m->{check(hasText(m.gui.screen(),"sanctuary.cycle.log_line"),"Faction event visible");check(hasText(m.gui.screen(),"sanctuary.progression.history_purchase"),"Purchase visible in history");});c.takeScreenshot("navigation076-history-fr");
click(c,"sanctuary.cycle.refresh");c.waitTicks(8);c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
server.runOnServer(s->{var p=player(s);var r=ProgressEffects.get(p).buy("names",4);for(String skill:ProgressRecord.SKILLS)while(r.rank(skill)<ProgressRecord.cap(skill))r=r.buy(skill,1);p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");});c.waitFor(m->ProgressionClient.progress.complete(),100);
click(c,"sanctuary.cycle.cycle");c.waitTicks(8);click(c,"sanctuary.cycle.ngp");click(c,"sanctuary.cycle.cancel");click(c,"sanctuary.cycle.ngp");click(c,"sanctuary.cycle.confirm");
c.waitFor(m->CycleClient.view.prestige()==1&&ProgressionClient.progress.cycle()==1,100);
c.runOnClient(m->{check(ProgressionClient.progress.inventoryRows()==1&&ProgressionClient.progress.names(),"Prestige resets skills and keeps ability");check(CycleClient.view.faction().name().equals("Navigateurs")&&CycleClient.view.charges()==1,"Faction and prestige charge preserved");check(!button(m.gui.screen(),"sanctuary.cycle.ngp").active,"Cannot repeat prestige");});
click(c,"sanctuary.cycle.log");c.waitTicks(5);c.takeScreenshot("navigation076-prestige-history");c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(5);
c.runOnClient(m->FamiliarClient.open(null));c.waitFor(m->FamiliarClient.profile!=null&&FamiliarClient.bond!=null,100);c.waitTicks(5);
c.runOnClient(m->check(!button(m.gui.screen(),"sanctuary.familiar.work").active,"Utility requires ability for normal player"));
server.runOnServer(s->{var p=player(s);FamiliarBattle.state(p).lastRequest=-100;FamiliarBattle.command(p,"work");check(!FamiliarBattle.state(p).workMode,"Server also refuses locked utility");CompanionService.save(p,CompanionService.record(p).purchase(4,System.currentTimeMillis()));CompanionService.send(p,"");FamiliarBattle.send(p,"");});c.waitTicks(10);
c.runOnClient(m->widgets(m.gui.screen()).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow().setScrollAmount(100));
click(c,"sanctuary.familiar.work");c.waitFor(m->FamiliarClient.view.work(),100);
c.runOnClient(m->check(widgets(m.gui.screen()).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow().scrollAmount()>=90,"Changing utility preserves scroll position"));
c.runOnClient(m->{check(!button(m.gui.screen(),"sanctuary.familiar.work_selected").active,"Selected utility visibly selected");check(hasText(m.gui.screen(),"sanctuary.familiar.selected_power"),"Selected power named");});
c.takeScreenshot("navigation076-utility");
c.runOnClient(m->{String name=FamiliarClient.name(FamiliarClient.profile.signature()).getString();widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(name)).findFirst().orElseThrow().onPress(enter());});c.waitFor(m->!FamiliarClient.view.work(),100);c.waitTicks(6);
click(c,"sanctuary.familiar.work");c.waitFor(m->FamiliarClient.view.work(),100);c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(6);
server.runOnServer(s->{var p=player(s);CompanionService.save(p,CompanionService.record(p).cooldown(System.currentTimeMillis()+60000));});c.getInput().pressKey(InputConstants.KEY_G);
c.waitFor(m->FamiliarClient.view.message().equals("work.cooldown"),100);
c.runOnClient(m->check(FamiliarClient.feedback(FamiliarClient.view.message()).getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.companion.cooldown"),"Utility feedback uses correct translations"));
System.out.println("NAVIGATION076_PASS: FR/EN scale 2/3, pause/arena/back, late close, purchase history, faction, prestige, locked/selected utility, G feedback");
}finally{intro.set(false);}
}
private static java.util.stream.Stream<net.minecraft.client.gui.components.events.GuiEventListener> widgets(net.minecraft.client.gui.components.events.GuiEventListener e){
return java.util.stream.Stream.concat(java.util.stream.Stream.of(e),e instanceof net.minecraft.client.gui.components.events.ContainerEventHandler h?h.children().stream().flatMap(Navigation076ClientChecks::widgets):java.util.stream.Stream.empty());
}
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 Button find(Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElse(null);}
private static Button button(Screen s,String key){return Objects.requireNonNull(find(s,key),"Missing button "+key+" on "+s.getClass().getSimpleName());}
private static void press(Screen s,String key){var b=button(s,key);check(b.active,"Disabled "+key+" view="+FamiliarClient.view+" record="+CompanionClient.record);b.onPress(enter());}
private static void click(ClientGameTestContext c,String key){c.runOnClient(m->press(m.gui.screen(),key));c.waitTicks(5);}
private static EditBox edit(Screen s){return widgets(s).filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow();}
private static boolean hasText(Screen s,String key){return widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w->contains(w.getMessage(),key));}
private static boolean contains(Component c,String key){if(c.getContents() instanceof TranslatableContents t&&t.getKey().equals(key))return true;return c.getSiblings().stream().anyMatch(s->contains(s,key));}
private static void check(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,164 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import fr.koka.sanctuary.mixin.MultiblockFurnaceAccess;
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.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.*;
import net.minecraft.world.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.entity.player.*;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.phys.*;
public final class Piston087ClientChecks implements FabricClientGameTest {
private static final BlockPos C=new BlockPos(15,85,15);
private static final AtomicBoolean movingSeen=new AtomicBoolean(),returnSeen=new AtomicBoolean();
private static net.minecraft.world.entity.animal.pig.Pig pig;
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Piston087 "+UUID.randomUUID());s.setSeed("87");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();
c.runOnClient(m->{if(!m.gui.hud.isHidden())m.gui.hud.toggle();m.options.bobView().set(false);m.options.fov().set(70);m.getWindow().setWindowed(1280,800);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(m.level==null)return;for(var f:Direction.values())for(int d=0;d<4;d++)if(m.level.getBlockEntity(C.relative(f,d)) instanceof net.minecraft.world.level.block.piston.PistonMovingBlockEntity be){
if(be.getMovedState().is(SuperPistons.HEAD))movingSeen.set(true);if(be.getMovedState().getBlock() instanceof SuperPistonBlocks.Base&&be.isSourcePiston())returnSeen.set(true);
}});
server.runOnServer(s->{var p=player(s);p.setGameMode(GameType.CREATIVE);p.getAbilities().flying=true;p.onUpdateAbilities();p.teleportTo(24,91,5);
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");});
// beta.089 regression: inherited piston predicates must never read absent EXTENDED on helper pieces.
c.runOnClient(m->verifyPredicates(m.level));
server.runOnServer(s->verifyPredicates(player(s).level()));
// Real wrench confirmation on vanilla components; then six orientations for both variants.
for(boolean sticky:new boolean[]{false,true})for(var facing:Direction.values()){
server.runOnServer(s->{var p=player(s);setup(p,facing,sticky,1);var l=p.level();
check(!(l.getBlockEntity(C) instanceof SuperPistonController),"Placing nine pistons does not assemble them");
wrench(p,C);check(!(l.getBlockEntity(C) instanceof SuperPistonController),"First click only previews");wrench(p,C);
check(l.getBlockEntity(C) instanceof SuperPistonController,"Second click creates controller");
camera(p,facing);
});
c.waitTicks(8);c.getInput().lookAt(C.relative(facing));c.waitTicks(5);
if(facing==Direction.NORTH)c.takeScreenshot("piston087-"+(sticky?"sticky":"normal")+"-closed");
server.runOnServer(s->power(player(s).level(),facing,true));
server.waitFor(s->idle(player(s).level(),3),200);
server.runOnServer(s->{var l=player(s).level();for(var pos:SuperPistons.tiles(C,facing,4))check(l.getBlockState(pos).is(Blocks.STONE),"Entire nine-block load moved exactly three blocks: "+facing);
for(var pos:SuperPistons.tiles(C,facing,3))check(l.getBlockState(pos).is(SuperPistons.HEAD),"Common head at depth three");});
c.waitTicks(7);c.takeScreenshot("piston087-"+(sticky?"sticky":"normal")+"-"+facing.getName());
c.runOnClient(m->{
for(var pos:SuperPistons.tiles(C,facing,3))check(!m.level.collidesWithSuffocatingBlock(m.player,new AABB(pos)),"Player contact with every head tile is safe: "+facing);
for(int depth=1;depth<3;depth++)check(!m.level.collidesWithSuffocatingBlock(m.player,new AABB(C.relative(facing,depth))),"Player contact with rod is safe: "+facing);
});
server.runOnServer(s->power(player(s).level(),facing,false));server.waitFor(s->idle(player(s).level(),0),200);
server.runOnServer(s->{var l=player(s).level();for(var pos:SuperPistons.tiles(C,facing,sticky?1:4))check(l.getBlockState(pos).is(Blocks.STONE),"Correct normal/sticky return: "+facing);
for(int depth=1;depth<=3;depth++)for(var pos:SuperPistons.tiles(C,facing,depth))check(!(l.getBlockState(pos).getBlock() instanceof SuperPistonBlocks.Part),"No leftover helper pieces");});
}
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,false,12);check(SuperPistons.assemble(p.level(),C,Direction.NORTH,false),"108-load fixture");power(p.level(),Direction.NORTH,true);});
server.waitFor(s->idle(player(s).level(),3),200);
server.runOnServer(s->{var l=player(s).level();int n=0;for(int d=1;d<=15;d++)for(var pos:SuperPistons.tiles(C,Direction.NORTH,d))if(l.getBlockState(pos).is(Blocks.STONE))n++;check(n==108,"108 blocks conserved after three steps");for(var pos:SuperPistons.tiles(C,Direction.NORTH,15))check(l.getBlockState(pos).is(Blocks.STONE),"Full 12-block deep load moves");});
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,false,12);p.level().setBlockAndUpdate(SuperPistons.tile(C,Direction.NORTH,0).relative(Direction.NORTH,13),Blocks.STONE.defaultBlockState());SuperPistons.assemble(p.level(),C,Direction.NORTH,false);power(p.level(),Direction.NORTH,true);});
c.waitTicks(25);server.runOnServer(s->{var l=player(s).level();check(idle(l,0),"109 blocks reject the whole stroke");});
// Obstacle at the second step: all nine stop, then resume after removal.
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,false,0);SuperPistons.assemble(p.level(),C,Direction.NORTH,false);p.level().setBlockAndUpdate(SuperPistons.tile(C,Direction.NORTH,0).relative(Direction.NORTH,2),Blocks.OBSIDIAN.defaultBlockState());power(p.level(),Direction.NORTH,true);});
server.waitFor(s->idle(player(s).level(),1),200);c.waitTicks(15);server.runOnServer(s->{var l=player(s).level();check(idle(l,1),"Obstacle stops every tile at step one");for(var pos:SuperPistons.tiles(C,Direction.NORTH,1))check(l.getBlockState(pos).is(SuperPistons.HEAD),"Head remains aligned");l.removeBlock(SuperPistons.tile(C,Direction.NORTH,0).relative(Direction.NORTH,2),false);});
server.waitFor(s->idle(player(s).level(),3),200);
// A chest remains untouched and keeps its inventory.
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,true,0);var l=p.level();l.setBlockAndUpdate(C.north(),Blocks.CHEST.defaultBlockState());((ChestBlockEntity)l.getBlockEntity(C.north())).setItem(0,new ItemStack(Items.DIAMOND,17));SuperPistons.assemble(l,C,Direction.NORTH,true);power(l,Direction.NORTH,true);});
c.waitTicks(20);server.runOnServer(s->{var l=player(s).level();check(idle(l,0)&&((ChestBlockEntity)l.getBlockEntity(C.north())).getItem(0).getCount()==17,"Container remains immovable with its exact contents");});
// Adhesion beyond the face, with slime/honey incompatibility.
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,true,0);var l=p.level();var corner=SuperPistons.tile(C,Direction.NORTH,8).north();l.setBlockAndUpdate(corner,Blocks.SLIME_BLOCK.defaultBlockState());l.setBlockAndUpdate(corner.east(),Blocks.STONE.defaultBlockState());l.setBlockAndUpdate(corner.above(),Blocks.HONEY_BLOCK.defaultBlockState());SuperPistons.assemble(l,C,Direction.NORTH,true);power(l,Direction.NORTH,true);});
server.waitFor(s->idle(player(s).level(),3),200);
server.runOnServer(s->{var l=player(s).level();var corner=SuperPistons.tile(C,Direction.NORTH,8).north();check(l.getBlockState(corner.north(3)).is(Blocks.SLIME_BLOCK)&&l.getBlockState(corner.east().north(3)).is(Blocks.STONE),"Slime pulls an adjacent load outside the 3x3 face");check(l.getBlockState(corner.above()).is(Blocks.HONEY_BLOCK),"Honey does not adhere to slime");power(l,Direction.NORTH,false);});
server.waitFor(s->idle(player(s).level(),0),200);
server.runOnServer(s->{var l=player(s).level();var corner=SuperPistons.tile(C,Direction.NORTH,8).north();check(l.getBlockState(corner).is(Blocks.SLIME_BLOCK)&&l.getBlockState(corner.east()).is(Blocks.STONE),"Sticky return restores attached load");});
// Moving platform transports a real entity using vanilla collision.
server.runOnServer(s->{var p=player(s);setup(p,Direction.UP,false,1);var l=p.level();pig=new net.minecraft.world.entity.animal.pig.Pig(net.minecraft.world.entity.EntityTypes.PIG,l);pig.setNoAi(true);pig.setPos(C.getX()+.5,C.getY()+2,C.getZ()+.5);l.addFreshEntity(pig);SuperPistons.assemble(l,C,Direction.UP,false);power(l,Direction.UP,true);});
server.waitFor(s->idle(player(s).level(),3),200);c.waitTicks(5);
server.runOnServer(s->{check(pig.getY()>=C.getY()+4.8,"Real entity carried upward three blocks: "+pig.getY());pig.discard();});
// Cut power during a step, preserving native in-flight serialization.
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,true,1);SuperPistons.assemble(p.level(),C,Direction.NORTH,true);power(p.level(),Direction.NORTH,true);});
server.waitFor(s->player(s).level().getBlockEntity(C) instanceof SuperPistonController be&&be.stroke==1&&!be.pending.isEmpty(),200);
server.runOnServer(s->{var l=player(s).level();var be=(SuperPistonController)l.getBlockEntity(C);var copy=BlockEntity.loadStatic(C,be.getBlockState(),be.saveWithFullMetadata(l.registryAccess()),l.registryAccess());check(copy instanceof SuperPistonController saved&&saved.stroke==1&&saved.pending.equals(be.pending),"Controller serializes in-flight progress and positions");
for(var pos:be.pending)if(l.getBlockEntity(pos) instanceof net.minecraft.world.level.block.piston.PistonMovingBlockEntity motion){var saved=BlockEntity.loadStatic(pos,motion.getBlockState(),motion.saveWithFullMetadata(l.registryAccess()),l.registryAccess());check(saved instanceof net.minecraft.world.level.block.piston.PistonMovingBlockEntity m&&m.getMovedState().equals(motion.getMovedState()),"Native moving block state serialized");}
power(l,Direction.NORTH,false);});
server.waitFor(s->idle(player(s).level(),0),200);
server.runOnServer(s->{for(var pos:SuperPistons.tiles(C,Direction.NORTH,1))check(player(s).level().getBlockState(pos).is(Blocks.STONE),"Interrupted sticky cycle keeps cargo");});
// Unloaded loads suspend instead of detaching a sticky piston; no forced chunk request.
server.runOnServer(s->{var l=player(s).level();var far=new BlockPos(300000,85,300000);boolean suspended=false;
try{SuperPistonPlan.resolve(l,List.of(far),Direction.NORTH,false,Set.of());}catch(SuperPistonPlan.Suspended expected){suspended=true;}
check(suspended&&!l.hasChunkAt(far),"Unloaded pull suspends without loading chunks");
check(SuperPistonPlan.resolve(l,List.of(C.atY(l.getMaxY()+1)),Direction.UP,true,Set.of())==null,"World height blocks movement");});
// Native breakable container drops one box with its inventory, never loose duplicate cargo.
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,false,0);var l=p.level();l.setBlockAndUpdate(C.north(),Blocks.SHULKER_BOX.defaultBlockState());((ShulkerBoxBlockEntity)l.getBlockEntity(C.north())).setItem(0,new ItemStack(Items.DIAMOND,17));SuperPistons.assemble(l,C,Direction.NORTH,false);power(l,Direction.NORTH,true);});
server.waitFor(s->idle(player(s).level(),3),200);
server.runOnServer(s->{var l=player(s).level();var boxes=l.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,new AABB(C).inflate(20),e->e.getItem().is(Items.SHULKER_BOX));check(boxes.size()==1,"One broken shulker box dropped");var contents=boxes.getFirst().getItem().get(DataComponents.CONTAINER);check(contents!=null&&contents.nonEmptyItemCopyStream().filter(i->i.is(Items.DIAMOND)).mapToInt(ItemStack::getCount).sum()==17,"Shulker contents preserved");boxes.forEach(net.minecraft.world.entity.Entity::discard);});
// The four-pixel front belongs to the moving head, including all six collision orientations.
server.runOnServer(s->{var l=player(s).level();for(var f:Direction.values()){
var state=SuperPistons.base(f,0,false,true);var bounds=state.getCollisionShape(l,C).bounds();double thickness=bounds.max(f.getAxis())-bounds.min(f.getAxis());check(Math.abs(thickness-.75)<.00001,"Extended base keeps twelve pixels: "+f);
var head=SuperPistons.headForBase(state,1).getCollisionShape(l,C).bounds();check(Math.abs(head.max(f.getAxis())-head.min(f.getAxis())-.25)<.00001,"Moving plate has four pixels: "+f);
}});
for(boolean sticky:new boolean[]{false,true}){
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,sticky,0);SuperPistons.assemble(p.level(),C,Direction.NORTH,sticky);p.teleportTo(C.getX()+6.5,C.getY()+3,C.getZ()-6.5);});
c.waitTicks(8);c.getInput().lookAt(C);c.waitTicks(4);c.takeScreenshot("piston087-"+(sticky?"sticky":"normal")+"-face-closed");
server.runOnServer(s->power(player(s).level(),Direction.NORTH,true));server.waitFor(s->idle(player(s).level(),3),200);c.waitTicks(6);c.takeScreenshot("piston087-"+(sticky?"sticky":"normal")+"-face-open");
}
// Loaded structure after real chunk unload and return; break/dismantle cleanup.
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,false,0);SuperPistons.assemble(p.level(),C,Direction.NORTH,false);power(p.level(),Direction.NORTH,true);camera(p,Direction.NORTH);});
server.waitFor(s->idle(player(s).level(),3),200);
server.runOnServer(s->player(s).teleportTo(400.5,90,400.5));c.waitFor(m->m.level.getChunkSource().getChunkNow(C.getX()>>4,C.getZ()>>4)==null,600);
server.runOnServer(s->camera(player(s),Direction.NORTH));c.waitFor(m->m.level.getChunkSource().getChunkNow(C.getX()>>4,C.getZ()>>4)!=null&&m.level.getBlockState(C).is(SuperPistons.NORMAL),600);server.waitFor(s->idle(player(s).level(),3),200);
server.runOnServer(s->{var p=player(s);p.level().destroyBlock(SuperPistons.tile(C,Direction.NORTH,0),true);});c.waitTicks(15);
server.runOnServer(s->{var l=player(s).level();for(int i=1;i<9;i++)check(l.getBlockState(SuperPistons.tile(C,Direction.NORTH,i)).is(Blocks.PISTON),"Broken structure restores remaining eight components");for(int d=1;d<=3;d++)for(var pos:SuperPistons.tiles(C,Direction.NORTH,d))check(l.getBlockState(pos).isAir(),"Head and rod removed after break");});
check(movingSeen.get(),"Client receives moving head data");check(returnSeen.get(),"Client receives final retraction data");
System.out.println("PISTON087_PASS: all-state suffocation/view/spawn/conductor predicates, client player collision on every head tile and rod, six directions x two variants, 108/109 load, obstacles, inventories, adhesion, entity collision, interruption, serialization, chunk reload and dismantle");
}
}
private static void verifyPredicates(net.minecraft.world.level.BlockGetter level){
for(var block:List.of(SuperPistons.NORMAL,SuperPistons.GLUANT,SuperPistons.HEAD,SuperPistons.ROD))for(var state:block.getStateDefinition().getPossibleStates()){
boolean closed=state.getBlock() instanceof SuperPistonBlocks.Base&&!state.getValue(net.minecraft.world.level.block.piston.PistonBaseBlock.EXTENDED);
check(state.isSuffocating(level,C)==closed,"Only retracted bases suffocate: "+state);
check(!state.isRedstoneConductor(level,C),"Piston pieces do not conduct through their volume");
state.isViewBlocking(level,C,new AABB(C));state.isValidSpawn(level,C,net.minecraft.world.entity.EntityTypes.PIG);
}
}
private static ServerPlayer player(net.minecraft.server.MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static void camera(ServerPlayer p,Direction f){var u=SuperPistons.u(f);var v=SuperPistons.v(f);p.teleportTo(C.getX()+.5+f.getStepX()*9+u.getStepX()*7+v.getStepX()*5,C.getY()+.5+f.getStepY()*9+u.getStepY()*7+v.getStepY()*5,C.getZ()+.5+f.getStepZ()*9+u.getStepZ()*7+v.getStepZ()*5);}
private static void power(ServerLevel l,Direction f,boolean on){l.setBlockAndUpdate(SuperPistons.tile(C,f,0).relative(f,-1),(on?Blocks.REDSTONE_BLOCK:Blocks.AIR).defaultBlockState());}
private static boolean idle(ServerLevel l,int n){return l.getBlockEntity(C) instanceof SuperPistonController be&&be.stroke==n&&be.pending.isEmpty();}
private static void setup(ServerPlayer p,Direction f,boolean sticky,int layers){
var l=p.level();p.closeContainer();p.setShiftKeyDown(false);p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Multiblocks.WRENCH));
for(var d:Direction.values())for(boolean glue:new boolean[]{false,true})SuperPistons.dismantle(l,C,d,glue);
for(var d:Direction.values())for(int depth=-16;depth<=16;depth++)for(var pos:SuperPistons.tiles(C,d,depth))if(!l.getBlockState(pos).isAir())l.setBlockAndUpdate(pos,Blocks.AIR.defaultBlockState());
for(int i=0;i<9;i++){var pos=SuperPistons.tile(C,f,i);l.setBlockAndUpdate(pos,(sticky?Blocks.STICKY_PISTON:Blocks.PISTON).defaultBlockState().setValue(net.minecraft.world.level.block.piston.PistonBaseBlock.FACING,f));}
for(int depth=1;depth<=layers;depth++)for(var pos:SuperPistons.tiles(C,f,depth))l.setBlockAndUpdate(pos,Blocks.STONE.defaultBlockState());
}
private static void wrench(ServerPlayer p,BlockPos pos){UseBlockCallback.EVENT.invoker().interact(p,p.level(),InteractionHand.MAIN_HAND,new BlockHitResult(Vec3.atCenterOf(pos),Direction.UP,pos,false));}
private static net.minecraft.client.input.KeyEvent enter(){return new net.minecraft.client.input.KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,119 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import java.util.stream.Stream;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.phys.Vec3;
/** Real client resource/model capture, widgets, previews and creative upload in a new flat world. */
public final class Plans072ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
button(screen,"sanctuary.progression.enter").onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Plans072 "+UUID.randomUUID());s.setSeed("72");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1800);var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setGameMode(GameType.CREATIVE);p.teleportTo(0.5,10,0.5);p.getAbilities().flying=true;p.onUpdateAbilities();
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set day");
});
c.waitFor(m->m.player.isCreative(),100);c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();});
long started=System.nanoTime();var supported=new ArrayList<String>();var refused=new ArrayList<String>();
c.runOnClient(m->{
var palette=new StatuePaletteUnchecked();
require(palette.value.nearest(0xFFFFFFFF)!=null,"Active resource textures produce a block palette");
for(var item:BuiltInRegistries.ITEM)if(item instanceof SpawnEggItem){
var type=SpawnEggItem.getType(item.getDefaultInstance());String id=BuiltInRegistries.ENTITY_TYPE.getKey(type).toString();
if(supported.contains(id)||refused.stream().anyMatch(v->v.startsWith(id+" ")))continue;
try{
var entity=type.create(m.level,EntitySpawnReason.MOB_SUMMONED);entity.setPos(m.player.position());entity.setId(Integer.MIN_VALUE+73);
var faces=StatueModels.capture(entity);require(faces.size()>=6,"Native geometry exists: "+id);supported.add(id);
}catch(Exception e){refused.add(id+" "+e.getMessage());}
}
});
require(supported.containsAll(List.of("minecraft:creeper","minecraft:cow","minecraft:pig","minecraft:chicken","minecraft:zombie","minecraft:sheep")),"Common native models are supported");
System.out.println("PLANS072 models="+supported.size()+" refused="+refused+" capture_ms="+(System.nanoTime()-started)/1_000_000);
c.getInput().pressKey(InputConstants.KEY_K);c.waitForScreen(PlansScreen.class);
c.takeScreenshot("plans072-create-en");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.generate").onPress(enter()));
c.waitFor(m->PlanClient.plan!=null&&!PlanClient.busy,400);
c.runOnClient(m->{require(PlanClient.plan.height()==32,"Requested model height");require(PlanClient.plan.cells().size()>100,"Detailed geometry becomes real material cells");
PlanClient.origin=new BlockPos(10,2,10);PlanClient.refresh();
});
c.takeScreenshot("plans072-plan-en");
var expected=new AtomicReference<BuildPlan>();c.runOnClient(m->expected.set(PlanClient.plan));
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.rotate").onPress(enter()));
c.runOnClient(m->{for(int i=0;i<3;i++)PlanClient.rotate();require(PlanClient.plan.equals(expected.get()),"UI and native rotations return to exact original plan");});
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.materials").onPress(enter()));c.takeScreenshot("plans072-materials-en");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.back_plan").onPress(enter()));
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.export").onPress(enter()));c.waitFor(m->!PlanClient.busy,200);
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(15.5,15,55.5));
c.runOnClient(m->m.gui.setScreen(null));c.waitFor(m->m.player.distanceToSqr(15.5,15,55.5)<2,100);
c.getInput().lookAt(new BlockPos(15,18,17));c.waitTicks(15);
c.getInput().pressKey(InputConstants.KEY_F1);c.takeScreenshot("plans072-ghost-creeper");c.getInput().pressKey(InputConstants.KEY_F1);
c.getInput().pressKey(InputConstants.KEY_K);c.waitForScreen(PlansScreen.class);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.paste").onPress(enter()));c.waitForScreen(ConfirmScreen.class);
c.clickScreenButton("gui.yes");
c.waitFor(m->PlanClient.completed==PlanClient.plan.cells().size(),400);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();for(var cell:expected.get().cells())require(p.level().getBlockState(new BlockPos(10,2,10).offset(cell.pos()))==cell.state(),"Server pasted every exact material");});
c.runOnClient(m->m.gui.setScreen(null));c.getInput().pressKey(InputConstants.KEY_F1);c.waitTicks(10);c.takeScreenshot("plans072-built-creeper");c.getInput().pressKey(InputConstants.KEY_F1);
// A real survival connection must reject an upload, even when sent without the GUI.
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setGameMode(GameType.SURVIVAL);p.getAbilities().invulnerable=true;});
c.waitFor(m->!m.player.isCreative(),100);
c.runOnClient(m->{var tiny=new BuildPlan("Survival",1,1,1,List.of(new BuildPlan.Cell(BlockPos.ZERO,Blocks.STONE.defaultBlockState())));PlanClient.select(tiny,Map.of());PlanClient.origin=new BlockPos(4,2,4);PlanClient.refresh();
ClientPlayNetworking.send(new PlanPayload(UUID.randomUUID(),PlanClient.origin,1,1,1,1,0,tiny.cells()));});
c.waitTicks(30);server.runOnServer(s->require(s.getPlayerList().getPlayers().getFirst().level().getBlockState(new BlockPos(4,2,4)).isAir(),"Survival spoofed paste leaves the world untouched"));
c.getInput().pressKey(InputConstants.KEY_K);c.waitForScreen(PlansScreen.class);
c.runOnClient(m->require(widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).noneMatch(b->key(b).equals("sanctuary.plans.paste")),"Survival UI only offers construction guidance"));
language(c,"fr_fr");c.takeScreenshot("plans072-survie-fr");
c.runOnClient(m->{PlanClient.layer=0;PlanClient.refresh();require(PlanClient.completed==0,"Free preview does not complete a block");});
// Ordinary native placement updates the plan and consumes the held material.
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().setItem(p.getInventory().getSelectedSlot(),new ItemStack(Items.STONE,2));
for(int x=3;x<=5;x++)for(int z=2;z<=5;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
p.level().setBlockAndUpdate(new BlockPos(4,1,4),Blocks.STONE.defaultBlockState());p.teleportTo(4.5,1,3.5);});
c.runOnClient(m->m.gui.setScreen(null));
c.waitFor(m->m.player.distanceToSqr(4.5,1,3.5)<1&&m.player.getMainHandItem().is(Items.STONE),150);
c.getInput().lookAt(new BlockPos(4,1,4));c.waitTicks(3);
c.runOnClient(m->System.out.println("PLANS072 survival hit="+m.hitResult+" player="+m.player.position()+" origin="+PlanClient.origin+" held="+m.player.getMainHandItem()));
c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);
c.waitFor(m->PlanClient.completed==1,150);
server.runOnServer(s->require(s.getPlayerList().getPlayers().getFirst().getMainHandItem().getCount()==1,"Survival native placement consumes exactly one block"));
c.runOnClient(m->{PlanClient.clear();m.gui.setScreen(new PlansScreen(null));});c.takeScreenshot("plans072-create-fr");
language(c,"en_us");
System.out.println("PLANS072 passed: native models + UV textures + active palette, creation widgets, 32-block statue, rotations, materials, gzip export, colored preview, real creative network paste, survival spoof rejection and paid native placement, FR/EN.");
}
}
private static void language(ClientGameTestContext c,String id){
var reload=c.computeOnClient(m->{m.getLanguageManager().setSelected(id);return m.reloadResourcePacks();});
c.waitFor(m->reload.isDone(),600);reload.join();c.waitFor(m->m.gui.overlay()==null,400);c.waitTicks(8);
}
private static final class StatuePaletteUnchecked {final StatuePalette value;StatuePaletteUnchecked(){try{value=new StatuePalette("mixed");}catch(Exception e){throw new IllegalStateException(e);}}}
private static Stream<GuiEventListener> widgets(ContainerEventHandler c){return c.children().stream().flatMap(w->w instanceof ContainerEventHandler child?Stream.concat(Stream.of(w),widgets(child)):Stream.of(w));}
private static String key(Button b){return b.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
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("Button missing: "+key));}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,68 @@
package fr.koka.sanctuary.client;
import java.util.Arrays;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.packs.PackSelectionScreen;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.repository.PackCompatibility;
/** Native default selection, actual resource priority, GUI atlas rendering and player opt-out. */
public final class ResourcePack070ClientChecks implements FabricClientGameTest {
private static final String ID="sanctuary:textures";
public void runTest(ClientGameTestContext c) {
var previous=c.computeOnClient(m->m.gui.screen());
c.runOnClient(m->{
var repo=m.getResourcePackRepository();
check(repo.getAvailableIds().contains(ID),"Built-in pack available: "+repo.getAvailableIds());
check(repo.getSelectedIds().contains(ID),"Pack enabled at startup without changing options");
check(repo.getPack(ID).getCompatibility()==PackCompatibility.COMPATIBLE,"Format accepted by exact Minecraft version");
for(String state:new String[]{"empty","half","full","empty_hunger","half_hunger","full_hunger"})
resource(m,"minecraft:textures/gui/sprites/hud/food_"+state+".png");
resource(m,"sanctuary:textures/gui/food_details.png");
check(!m.getResourceManager().getResource(Identifier.withDefaultNamespace("textures/gui/sprites/hud/air.png")).orElseThrow().sourcePackId().equals(ID),"Breathing keeps native resources");
});
c.takeScreenshot("resourcepack070-title-default");
c.runOnClient(m->m.gui.setScreen(new PackSelectionScreen(m.getResourcePackRepository(),repo->{},m.getResourcePackDirectory(),Component.translatable("resourcePack.title"))));
c.waitTicks(4);c.takeScreenshot("resourcepack070-selected");
c.runOnClient(m->m.gui.setScreen(new Screen(Component.literal("Resource pack preview")) {
@Override public void extractRenderState(GuiGraphicsExtractor g,int x,int y,float delta) {
g.fill(0,0,width,height,0xff171717);
g.centeredText(font,Component.translatable("sanctuary.resource_pack.description","beta.070"),width/2,35,0xffffffff);
String[] states={"empty","half","full","empty_hunger","half_hunger","full_hunger"};
for(int i=0;i<states.length;i++)g.blitSprite(RenderPipelines.GUI_TEXTURED,Identifier.withDefaultNamespace("hud/food_"+states[i]),width/2-63+i*22,75,18,18);
for(int i=0;i<4;i++)g.blit(RenderPipelines.GUI_TEXTURED,Identifier.parse("sanctuary:textures/gui/food_details.png"),width/2-36+i*22,115,i*9,0,18,18,9,9,256,256);
}
}));
c.waitTicks(4);c.takeScreenshot("resourcepack070-food-native-atlas");
c.runOnClient(m->{
var repo=m.getResourcePackRepository();repo.removePack(ID);m.options.updateResourcePacks(repo);
});
c.waitFor(m->!source(m).equals(ID),1000);
c.runOnClient(m->{
var repo=m.getResourcePackRepository();repo.reload();m.options.loadSelectedResourcePacks(repo);
check(!repo.getSelectedIds().contains(ID),"Explicit disable survives pack rediscovery");
repo.addPack(ID);m.options.updateResourcePacks(repo);
});
c.waitFor(m->source(m).equals(ID),1000);
c.runOnClient(m->m.gui.setScreen(previous));
System.out.println("RESOURCEPACK070_PASS: default activation, exact imported textures override native assets, format compatible, breathing vanilla, disable retained and re-enable works");
}
private static String source(Minecraft m) {
return m.getResourceManager().getResource(Identifier.withDefaultNamespace("textures/gui/sprites/hud/food_full.png")).orElseThrow().sourcePackId();
}
private static void resource(Minecraft m,String name) {
var id=Identifier.parse(name);var resource=m.getResourceManager().getResource(id).orElseThrow();
check(resource.sourcePackId().equals(ID),"Active resource priority: "+name+" from "+resource.sourcePackId());
var path=FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow().findPath("resourcepacks/textures/assets/"+id.getNamespace()+"/"+id.getPath()).orElseThrow();
try(var in=resource.open()) {check(Arrays.equals(in.readAllBytes(),java.nio.file.Files.readAllBytes(path)),"Resolved texture bytes: "+name);}
catch(java.io.IOException error){throw new AssertionError(error);}
}
private static void check(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,98 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.companion.CompanionService;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.ProgressionService;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.CustomData;
import net.minecraft.world.level.block.Blocks;
/** Real single-player orders without operator permissions or purchased familiar abilities. */
public final class Solo079ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
var intro=new AtomicBoolean(true);
var messages=ConcurrentHashMap.<String>newKeySet();
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(FamiliarClient.view!=null&&!FamiliarClient.view.message().isEmpty())messages.add(FamiliarClient.view.message());
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());
button(screen,"sanctuary.progression.enter").onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Solo079 "+UUID.randomUUID());s.setSeed("79");s.setAllowCommands(false);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();var target=new AtomicReference<Mob>();
server.runOnServer(s->{var p=player(s);check(!ProgressionService.canOperate(p),"Normal solo has no operator permissions");check(!CompanionService.record(p).unlocked(),"No familiar active ability bought");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set night");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"difficulty normal");
for(int x=-18;x<=18;x++)for(int z=-18;z<=18;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
p.getInventory().clearContent();p.teleportTo(.5,1,.5);equip(p,Items.WOLF_SPAWN_EGG,FamiliarPersonality.PASSIVE);
});c.waitTicks(15);
// The actual held-H menu sends recall and follow through the play connection.
order(c,"recall");c.waitTicks(12);
server.runOnServer(s->check(FamiliarBattle.bond(player(s)).recalled()&&FamiliarService.active(player(s))==null,"Held H -> Recall actually removes the familiar"));
order(c,"follow");c.waitTicks(20);
server.runOnServer(s->check(!FamiliarBattle.bond(player(s)).recalled()&&FamiliarService.active(player(s))!=null,"Follow summons the recalled familiar"));
order(c,"protect");c.waitTicks(12);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.state(p).order==FamiliarBattle.Order.PROTECT&&p.getUUID().equals(FamiliarBattle.state(p).protectedEntity),"Held H -> Protect establishes a personal guard without operator rights");});
for(var item:new Item[]{Items.WOLF_SPAWN_EGG,Items.SLIME_SPAWN_EGG,Items.CHICKEN_SPAWN_EGG}) {
server.runOnServer(s->{var p=player(s);equip(p,item,FamiliarPersonality.PASSIVE);p.teleportTo(.5,1,.5);FamiliarService.active(p).setPos(1.5,1,.5);target.set(enemy(p,.5,1,6.5));});c.waitTicks(15);
c.getInput().lookAt(new BlockPos(0,2,6));c.waitTicks(5);order(c,"engage");c.waitTicks(12);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==target.get()&&FamiliarBattle.state(p).explicitTarget,"Held H -> Attack gives a target: "+item);});
c.waitTicks(200);
server.runOnServer(s->{var p=player(s);var st=FamiliarBattle.state(p);var pet=FamiliarService.active(p);check(target.get().getHealth()<100&&target.get().getLastHurtByMob()==pet,"Commanded familiar actually damages enemy: "+item+" pet="+pet.position()+" target="+target.get().position()+" current="+st.target+" next="+st.nextInitiative+" tick="+p.tickCount+" cast="+(st.cast==null?"none":st.cast.age)+" sight="+pet.hasLineOfSight(target.get()));target.get().discard();FamiliarBattle.cancel(p);});
}
// Follow pauses spontaneous pursuit, never an immediate response to real damage.
for(var item:new Item[]{Items.WOLF_SPAWN_EGG,Items.SLIME_SPAWN_EGG,Items.CHICKEN_SPAWN_EGG}) {
server.runOnServer(s->{var p=player(s);equip(p,item,FamiliarPersonality.PASSIVE);p.setHealth(p.getMaxHealth());p.teleportTo(.5,1,.5);FamiliarService.active(p).setPos(1.5,1,.5);target.set(enemy(p,5.5,1,.5));
FamiliarBattle.command(p,"follow");
p.hurtServer(p.level(),p.damageSources().mobAttack(target.get()),1);
check(FamiliarBattle.target(p)==target.get(),"Passive familiar defends non-operator solo player immediately after Follow: "+item);
});c.waitTicks(200);
server.runOnServer(s->{check(target.get().getHealth()<100&&target.get().getLastHurtByMob()==FamiliarService.active(player(s)),"Autonomous defence really lands a hit: "+item);target.get().discard();});
}
check(messages.containsAll(java.util.Set.of("recalled","order.following","order.attacking","order.protecting")),"Accepted menu orders have client feedback: "+messages);
c.takeScreenshot("solo079-defence");System.out.println("SOLO079_PASS: no operator, no purchased ability, held-H menu, wolf/slime/chicken damage, real defence");
} finally {intro.set(false);c.getInput().releaseKey(InputConstants.KEY_H);}
}
private static void order(ClientGameTestContext c,String action) {
c.getInput().holdKey(InputConstants.KEY_H);c.waitFor(m->m.gui.screen() instanceof FamiliarOrdersScreen,100);c.getInput().releaseKey(InputConstants.KEY_H);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.familiar."+action).onPress(enter()));
}
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 void equip(ServerPlayer p,Item item,FamiliarPersonality personality) {
p.stopRiding();CarryService.detach(p);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);p.removeAttached(FamiliarBattle.COMBAT_LOCK);p.removeAttached(FamiliarBattle.LAST_FAMILIAR);
var egg=new ItemStack(item);var scale=new CompoundTag();scale.putInt("schema",1);scale.putInt("per_mille",1000);CustomData.update(DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,scale));
var profile=FamiliarBattle.world(p.level().getServer()).catalog.forEgg(egg);UUID id;do{id=UUID.randomUUID();}while(FamiliarPersonality.of(id,profile.id())!=personality);
FamiliarBattle.world(p.level().getServer()).store.put(FamiliarBond.create(id,profile,0,"test",p.getUUID()));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,FamiliarIdentity.assign(egg,id));
CompanionService.tick(p);p.tickCount+=10-p.tickCount%10;FamiliarService.tick(p);check(FamiliarService.active(p)!=null,"Familiar spawned: "+item);p.inventoryMenu.broadcastChanges();
}
private static Mob enemy(ServerPlayer p,double x,double y,double z){var e=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);e.setNoAi(true);e.getAttribute(Attributes.MAX_HEALTH).setBaseValue(100);e.setHealth(100);e.setPos(x,y,z);p.level().addFreshEntity(e);return e;}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean condition,String message){if(!condition)throw new AssertionError(message);}
}
@@ -0,0 +1,90 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.realtime.RealtimeService;
import fr.koka.sanctuary.weather.*;
import java.nio.file.*;
import java.time.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.attribute.EnvironmentAttributes;
import net.minecraft.world.level.storage.LevelResource;
/** Operator command tree, actual chat packets, daily persistence and native atmosphere. */
public final class Weather082ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
var intro=new AtomicBoolean(true);byte[] prior=readConfig();
checked(()->{Files.createDirectories(WeatherService.CONFIG.getParent());Files.writeString(WeatherService.CONFIG,WeatherConfig.DEFAULT.json());});
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("Weather082 "+UUID.randomUUID());s.setSeed("82");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->checked(()->{
at(s,Instant.parse("2026-09-16T10:00:00Z"));WeatherService.reload(s);
var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;
var commands=s.getCommands().getDispatcher();var root=commands.getRoot();var sanctuary=root.getChild("sanctuary").getChild("weather");var nativeWeather=root.getChild("weather");
s.getWorldData().setAllowCommands(false);var source=p.createCommandSourceStack();var before=WeatherService.session(s).state();
for(var kind:DailyWeather.Kind.values()) {check(!sanctuary.getChild(kind.key()).canUse(source),"Operator choice hidden without Allow Commands: "+kind);refused(()->commands.execute("sanctuary weather "+kind.key(),source));}
for(var kind:List.of(DailyWeather.Kind.OVERCAST,DailyWeather.Kind.FOG,DailyWeather.Kind.SHOWERS)){check(!nativeWeather.getChild(kind.key()).canUse(source),"Native alias hidden for non-operator");refused(()->commands.execute("weather "+kind.key(),source));}
check(WeatherService.session(s).state().equals(before),"Rejected commands cannot alter weather");
s.getCommands().sendCommands(p);
}));
c.waitFor(m->m.player.connection.getCommands().getRoot().getChild("sanctuary").getChild("weather").getChild("fog")==null,200);
server.runOnServer(s->{s.getWorldData().setAllowCommands(true);s.getCommands().sendCommands(s.getPlayerList().getPlayers().getFirst());});
c.waitFor(m->{var weather=m.player.connection.getCommands().getRoot().getChild("weather");return weather!=null&&weather.getChild("fog")!=null;},200);
c.runOnClient(m->{var root=m.player.connection.getCommands().getRoot();for(String key:List.of("clear","rain","thunder","overcast","fog","showers"))check(root.getChild("weather").getChild(key)!=null&&root.getChild("sanctuary").getChild("weather").getChild(key)!=null,"All six profiles available to operator completion: "+key);});
server.runOnServer(s->checked(()->{s.getCommands().getDispatcher().execute("sanctuary time vanilla",s.createCommandSourceStack());WeatherService.setMode(s,false);}));
c.waitFor(m->!WeatherClient.state().active(),200);
// All six choices go over the player's real chat-command connection.
for(var kind:List.of(DailyWeather.Kind.OVERCAST,DailyWeather.Kind.FOG,DailyWeather.Kind.SHOWERS,DailyWeather.Kind.RAIN,DailyWeather.Kind.THUNDER,DailyWeather.Kind.CLEAR)) {
c.runOnClient(m->m.player.connection.sendCommand("sanctuary weather "+kind.key()));
c.waitFor(m->WeatherClient.state().active()&&WeatherClient.state().kind()==kind,200);
server.runOnServer(s->{var now=RealtimeService.civilTime(s);var state=WeatherService.session(s).state();var weather=s.overworld().getWeatherData();check(state.weather()==kind&&state.realtime()&&state.date()==now.toLocalDate().toEpochDay(),"Chosen civil-day profile committed: "+kind);check(weather.isRaining()==DailyWeather.raining(kind,s.overworld().getSeed(),now)&&weather.isThundering()==kind.thundering(),"Native precipitation matches "+kind);check(!RealtimeService.active(s.overworld()),"Weather choice never changes solar mode");});
}
for(var kind:List.of(DailyWeather.Kind.OVERCAST,DailyWeather.Kind.FOG,DailyWeather.Kind.SHOWERS)){
c.runOnClient(m->m.player.connection.sendCommand("weather "+kind.key()));c.waitFor(m->WeatherClient.state().kind()==kind,200);
if(kind==DailyWeather.Kind.FOG){c.waitTicks(205);c.runOnClient(m->check(m.level.environmentAttributes().getDimensionValue(EnvironmentAttributes.FOG_END_DISTANCE)<=80.01,"Commanded fog reaches native rendering"));}
}
server.runOnServer(s->checked(()->{
var path=s.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-weather.json");var before=WeatherService.session(s).state();byte[] bytes=Files.readAllBytes(path);
WeatherService.reload(s);check(WeatherService.session(s).state().equals(before)&&Arrays.equals(bytes,Files.readAllBytes(path)),"Reload retains chosen profile without rewriting it");
check(s.getCommands().getDispatcher().execute("weather showers",s.createCommandSourceStack())==1&&Arrays.equals(bytes,Files.readAllBytes(path)),"Console alias is idempotent");
var current=RealtimeService.civilTime(s);var midnight=current.toLocalDate().plusDays(1).atStartOfDay(current.getZone());at(s,midnight.plusSeconds(1).toInstant());
}));server.waitFor(s->WeatherService.session(s).state().date()==RealtimeService.civilTime(s).toLocalDate().toEpochDay(),200);
server.runOnServer(s->checked(()->{
var now=RealtimeService.civilTime(s);check(WeatherService.session(s).state().date()==now.toLocalDate().toEpochDay()&&WeatherService.session(s).state().weather()==DailyWeather.at(s.overworld().getSeed(),now.toLocalDate(),WeatherConfig.DEFAULT),"Daily forecast resumes on the next civil date");
var commands=s.getCommands().getDispatcher();var source=s.createCommandSourceStack();commands.execute("sanctuary weather vanilla",source);commands.execute("weather rain 600",source);check(s.overworld().getWeatherData().isRaining(),"Native weather commands remain available in vanilla mode");
commands.execute("weather fog",source);check(WeatherService.active(s.overworld())&&WeatherService.session(s).state().weather()==DailyWeather.Kind.FOG,"Alias activates weather from vanilla mode");
Files.writeString(WeatherService.CONFIG,new WeatherConfig(false,15,5,10,3,2).json());WeatherService.reload(s);var before=WeatherService.session(s).state();
check(commands.execute("weather overcast",source)==0&&WeatherService.session(s).state().equals(before)&&!WeatherService.active(s.overworld()),"Disabled weather configuration refuses choices");
Files.writeString(WeatherService.CONFIG,WeatherConfig.DEFAULT.json());WeatherService.reload(s);
var path=s.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-weather.json");byte[] bytes=Files.readAllBytes(path);Files.writeString(path,"{external-conflict");var retained=WeatherService.session(s).state();
check(commands.execute("weather overcast",source)==0&&WeatherService.session(s).state().equals(retained)&&Files.readString(path).equals("{external-conflict"),"Save conflict refuses weather change before native side effects");Files.write(path,bytes);
}));
for(String language:List.of("fr_fr","en_us"))c.runOnClient(m->{m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());check(!WeatherService.label("selected",WeatherService.label("fog"),"2026-09-16","Europe/Paris").getString().contains("sanctuary.weather."),"Localized confirmation "+language);});
System.out.println("WEATHER082_PASS: player commands, operator completion and denials, six choices, native aliases, client fog, current-day persistence, midnight rollover, vanilla return, config disable, atomic save refusal, FR/EN");
}finally{intro.set(false);checked(()->{if(prior==null)Files.deleteIfExists(WeatherService.CONFIG);else Files.write(WeatherService.CONFIG,prior);});}
}
private static byte[] readConfig(){try{return Files.exists(WeatherService.CONFIG)?Files.readAllBytes(WeatherService.CONFIG):null;}catch(Exception e){throw new RuntimeException(e);}}
private static void at(MinecraftServer s,Instant instant)throws Exception{var field=RealtimeService.Session.class.getDeclaredField("clock");field.setAccessible(true);field.set(RealtimeService.session(s),Clock.fixed(instant,ZoneOffset.UTC));RealtimeService.tick(s);}
private interface Checked{void run()throws Exception;}
private static void checked(Checked task){try{task.run();}catch(Exception e){throw new RuntimeException(e);}}
private static void refused(Checked task)throws Exception{try{task.run();throw new AssertionError("Non-operator command accepted");}catch(CommandSyntaxException expected){}}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean value,String reason){if(!value)throw new AssertionError(reason);}
}
@@ -0,0 +1,36 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.progression.ProgressionPayloads;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
/** Native background captures, with the complete original score after a separate welcome fade. */
public final class Welcome083ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var previous=c.computeOnClient(m->m.gui.screen());
var clock=new AtomicLong();var completed=new AtomicInteger();
var uuid=UUID.fromString("01234567-89ab-cdef-fedc-ba9876543210");
c.runOnClient(m->{m.getWindow().setWindowed(1280,800);m.options.guiScale().set(2);m.resizeGui();
m.gui.setScreen(new HelloWorldScreen(new ProgressionPayloads.Hello("Sanctuary",List.of(0x55AAFF,0xFFAA55),""),()->{}));});
c.waitTicks(3);c.takeScreenshot("welcome083-gray");
c.runOnClient(m->m.gui.setScreen(new IntroScreen(uuid,skipped->completed.incrementAndGet(),clock::get,true)));
for(int millis:new int[]{0,250,500,750,1000,4000,24500,30000}) {
clock.set(millis*1_000_000L);c.waitTicks(2);c.takeScreenshot("welcome083-intro-"+millis);
if(millis<24500)c.runOnClient(m->check(((IntroScreen)m.gui.screen()).waitingForMusic(),"Music still waits for portal reveal"));
if(millis>=24500)c.runOnClient(m->check(!((IntroScreen)m.gui.screen()).waitingForMusic(),"Music begins with portal"));
check(completed.get()==0,"Original 29.5-second intro is not shortened by welcome fade");
}
clock.set(30_600_000_000L);c.waitTicks(3);
check(completed.get()==1,"Completion exactly once after fade plus full sequence");
c.runOnClient(m->m.gui.screen().onClose());check(completed.get()==1,"Closing cannot duplicate completion");
clock.set(0);c.runOnClient(m->m.gui.setScreen(new IntroScreen(uuid,skipped->completed.incrementAndGet(),clock::get)));
c.waitTicks(2);c.takeScreenshot("welcome083-replay-black");
c.runOnClient(m->m.gui.setScreen(previous));check(completed.get()==1,"Replacing replay never acknowledges admission");
System.out.println("WELCOME083_PASS: plain welcome gray, monotonic one-second gray-to-black fade, complete intro, portal music, black replay, no duplicate completion");
}
private static void check(boolean value,String reason) {if(!value)throw new AssertionError(reason);}
}
@@ -0,0 +1,140 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.boats.*;
import fr.koka.sanctuary.client.TiledBoatModel;
import fr.koka.sanctuary.progression.SanctuaryGameplay;
import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.core.BlockPos;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.player.Input;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
/** Actual native block shapes, vehicle movement and networked client controls in a development world. */
public final class Boat065ClientChecks implements FabricClientGameTest {
private static final double EPS=1.0E-5;
public void runTest(ClientGameTestContext c) {
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Boat065 "+UUID.randomUUID());s.setSeed("65");s.setAllowCommands(true);s.getGameRules().set(SanctuaryGameplay.ENABLED,false,null);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("minecraft:flat")).findFirst().orElseThrow());}).create()) {
var server=world.getServer();
server.runOnServer(s->{
var level=s.overworld();walls(level,Blocks.STONE.defaultBlockState());
var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;
int moves=0,turns=0;
for(var variant:CrewBoats.variants()) {
var boat=variant.type().create(level,EntitySpawnReason.COMMAND);
for(int angle=0;angle<360;angle+=45)for(int axis=0;axis<2;axis++)for(int sign:new int[]{-1,1}) {
boat.setYRot(angle);boat.setPos(0,20,0);boat.setDeltaMovement(Vec3.ZERO);
var motion=axis==0?new Vec3(sign*20,0,0):new Vec3(0,0,sign*20);
boat.move(MoverType.SELF,motion);inside(boat);check(boat.horizontalCollision,"Native collision reports contact");
check(boat.position().horizontalDistance()>2,"Craft moved before the wall");
Vec3 before=boat.position();boat.move(MoverType.SELF,motion.scale(-.075));
check(Math.abs(boat.position().distanceTo(before)-1.5)<EPS,"Can back away from contact");inside(boat);moves++;
}
for(int sx:new int[]{-1,1})for(int sz:new int[]{-1,1}) {
boat.setYRot(33);boat.setPos(0,20,0);boat.move(MoverType.SELF,new Vec3(20*sx,0,20*sz));inside(boat);moves++;
}
check(p.startRiding(boat,true,true),"Pilot mounted");
p.setLastClientInput(new Input(false,false,false,true,false,false,false));
for(int angle:new int[]{0,90,180,270}) {
boat.setYRot(angle);boat.setPos(8-boat.getBoundingBox().getXsize()/2-.01,20,0);
check(level.noCollision(boat,boat.getBoundingBox().deflate(EPS)),"Rotation fixture starts clear");
for(int tick=0;tick<20;tick++){boat.control(Vec3.ZERO);boat.move(MoverType.SELF,new Vec3(.35,0,0));inside(boat);}
turns++;
}
// Both endpoint envelopes fit; only the middle of this five-degree turn hits the wall.
boat.setPos(0,20,0);boat.setYRot(0);
for(int tick=0;tick<160;tick++)boat.control(Vec3.ZERO); // Native angular inertia reaches its 5° target in free space.
check(Math.abs(boat.getYRot())>90,"Free turning retained");
double halfWidth=(variant.raft()?variant.columns()*1.25:variant.columns()+.25)/2;
double halfLength=(variant.rows()*1.75+(variant.raft()?0:.25))/2;
float from=(float)Math.toDegrees(Math.atan2(halfLength,halfWidth))-2.5f;
boat.setYRot(from);boat.setPos(8-Math.hypot(halfWidth,halfLength)+.001,20,0);
check(level.noCollision(boat,boat.getBoundingBox().deflate(EPS)),"Arc starts clear");
boat.setYRot(from+5);check(level.noCollision(boat,boat.getBoundingBox().deflate(EPS)),"Arc ends clear");boat.setYRot(from);
boat.control(Vec3.ZERO);check(boat.getYRot()==from,"Swept corner arc rejected before touching wall");inside(boat);turns++;
p.stopRiding();p.setLastClientInput(Input.EMPTY);boat.discard();
}
// Fences and panes must use their native voxel collision shapes, even at high displacement.
int thin=0;
for(var block:List.of(Blocks.OAK_FENCE,Blocks.GLASS_PANE)) {
walls(level,block.defaultBlockState());
for(String wood:List.of("oak","bamboo"))for(var shape:CrewBoats.SHAPES)for(int axis=0;axis<2;axis++)for(int sign:new int[]{-1,1}) {
var boat=CrewBoats.variant(wood,shape.columns(),shape.rows()).type().create(level,EntitySpawnReason.COMMAND);
boat.setYRot(27);boat.setPos(0,20,0);boat.move(MoverType.SELF,axis==0?new Vec3(sign*20,0,0):new Vec3(0,0,sign*20));
check(level.noCollision(boat,boat.getBoundingBox().deflate(EPS)),"Thin obstacle not intersected");
check(Math.abs(boat.getX())<8&&Math.abs(boat.getZ())<8&&boat.horizontalCollision,"No tunneling through thin obstacle");boat.discard();thin++;
}
}
walls(level,Blocks.STONE.defaultBlockState());
for(int x=-8;x<8;x++)for(int z=-8;z<8;z++) {
level.setBlockAndUpdate(new BlockPos(x,18,z),Blocks.STONE.defaultBlockState());
level.setBlockAndUpdate(new BlockPos(x,19,z),Blocks.WATER.defaultBlockState());
}
p.teleportTo(0,25,0);
System.out.println("BOAT065_MATRIX_PASS variants="+CrewBoats.variants().size()+" moves="+moves+" turns="+turns+" thin="+thin);
});
// Compare real mesh vertices (including rails, excluding decorative moving paddles) against each collision box.
c.runOnClient(m->{
for(var v:CrewBoats.variants())for(int yaw=0;yaw<360;yaw+=45) {
var boat=v.type().create(m.level,EntitySpawnReason.COMMAND);boat.setYRot(yaw);boat.setPos(0,20,0);var bounds=boat.getBoundingBox();
var pose=new com.mojang.blaze3d.vertex.PoseStack();pose.rotateDegrees(com.mojang.math.Axis.YP,180-yaw);pose.scale(-1,-1,1);pose.rotateDegrees(com.mojang.math.Axis.YP,90);
TiledBoatModel.hull(v.columns(),v.rows(),v.raft()).bakeRoot().visit(pose,(at,path,index,cube)->{
if(path.contains("paddle"))return;
for(float x:new float[]{cube.minX,cube.maxX})for(float y:new float[]{cube.minY,cube.maxY})for(float z:new float[]{cube.minZ,cube.maxZ}) {
var vertex=at.pose().transformPosition(new org.joml.Vector3f(x/16,y/16,z/16));
check(vertex.x>=bounds.minX-EPS&&vertex.x<=bounds.maxX+EPS&&vertex.z>=bounds.minZ-EPS&&vertex.z<=bounds.maxZ+EPS,"Rendered hull inside collision: "+v.wood()+" "+v.columns()+"x"+v.rows()+" "+path);
}
});boat.discard();
}
});
c.runOnClient(m->m.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK));
int live=0;
for(String wood:List.of("oak","bamboo"))for(var shape:CrewBoats.SHAPES) {
final float yaw=(live%4)*90;final boolean flying=live%2==0;live++;
var id=new java.util.concurrent.atomic.AtomicInteger();
server.runOnServer(s->{
cleanup(s);var p=s.getPlayerList().getPlayers().getFirst();
var boat=CrewBoats.variant(wood,shape.columns(),shape.rows()).type().create(s.overworld(),EntitySpawnReason.COMMAND);
boat.setPos(0,flying?25:20,0);boat.setYRot(yaw);s.overworld().addFreshEntity(boat);id.set(boat.getId());
if(flying){var motor=EntityTypes.ALLAY.create(s.overworld(),EntitySpawnReason.COMMAND);motor.setPos(boat.position());motor.setNoAi(true);s.overworld().addFreshEntity(motor);motor.startRiding(boat);}
p.teleportTo(boat.getX(),boat.getY(),boat.getZ());p.startRiding(boat,true,true);
});
c.waitFor(m->m.player.getVehicle()!=null&&m.player.getVehicle().getId()==id.get(),200);
c.getInput().lookAt(yaw,25);c.getInput().holdKey(o->o.keyUp);if(flying)c.getInput().holdKey(o->o.keyJump);c.waitTicks(65);
c.getInput().holdKey(o->o.keyRight);c.waitTicks(15);c.getInput().releaseKey(o->o.keyRight);c.getInput().releaseKey(o->o.keyUp);
server.runOnServer(s->{var b=(CrewBoat)s.overworld().getEntity(id.get());check(b!=null&&b.getPassengers().stream().anyMatch(net.minecraft.server.level.ServerPlayer.class::isInstance),"Live pilot stays seated");inside(b);check(b.position().horizontalDistance()>2,"Native keys moved vessel");});
c.waitTicks(5);c.runOnClient(m->inside((CrewBoat)m.player.getVehicle()));
if(shape.columns()==3&&shape.rows()==3)c.takeScreenshot("boat065-"+wood+"-wall-contact");
var before=new AtomicReference<Vec3>();server.runOnServer(s->before.set(s.overworld().getEntity(id.get()).position()));
c.getInput().holdKey(o->o.keyDown);c.waitTicks(25);c.getInput().releaseKey(o->o.keyDown);if(flying)c.getInput().releaseKey(o->o.keyJump);
server.runOnServer(s->{var b=(CrewBoat)s.overworld().getEntity(id.get());inside(b);check(b.position().subtract(before.get()).horizontalDistance()>.15,"Native reverse input frees craft");});
System.out.println("BOAT065_LIVE_PASS "+wood+" "+shape.columns()+"x"+shape.rows()+" yaw="+yaw+" flying="+flying);
}
server.runOnServer(Boat065ClientChecks::cleanup);
System.out.println("BOAT065_PASS native collision, meshes and "+live+" piloted water/flight cases");
} finally {
c.getInput().releaseKey(o->o.keyUp);c.getInput().releaseKey(o->o.keyDown);c.getInput().releaseKey(o->o.keyRight);c.getInput().releaseKey(o->o.keyJump);
}
}
private static void cleanup(MinecraftServer server) {
var p=server.getPlayerList().getPlayers().getFirst();var old=p.getVehicle();p.stopRiding();
if(old!=null)old.getSelfAndPassengers().toList().reversed().forEach(Entity::discard);
}
private static void walls(ServerLevel level,net.minecraft.world.level.block.state.BlockState state) {
for(int y=18;y<=70;y++)for(int i=-9;i<=8;i++) {
level.setBlockAndUpdate(new BlockPos(-9,y,i),state);level.setBlockAndUpdate(new BlockPos(8,y,i),state);
level.setBlockAndUpdate(new BlockPos(i,y,-9),state);level.setBlockAndUpdate(new BlockPos(i,y,8),state);
}
}
private static void inside(CrewBoat b) {
var box=b.getBoundingBox();check(box.minX>=-8-EPS&&box.maxX<=8+EPS&&box.minZ>=-8-EPS&&box.maxZ<=8+EPS,"Craft remains inside walls: "+b.position()+" "+box);
check(b.level().noBlockCollision(b,box.deflate(EPS)),"Hull does not overlap blocks");
}
private static void check(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,92 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.boats.*;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import java.util.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.inventory.ContainerScreen;
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.world.Container;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.vehicle.boat.AbstractBoat;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
/** Native cursor and mouse packets, including actual mounted mob damage and head dispenser activation. */
public final class BoatPassengers091ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
ClientTickEvents.END_CLIENT_TICK.register(m->{
if(m.gui.screen() instanceof IntroScreen s)s.onClose();
if(m.gui.screen() instanceof HelloWorldScreen s){
Starter039TestSupport.selectFirst(s);
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());
}
});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("BoatPassengers091 "+UUID.randomUUID());s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
world.getConnection().waitForChunksDownload(2400);
c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.getInventory().clearContent();AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);
for(int x=-8;x<=8;x++)for(int z=-8;z<=8;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
});
var types=new ArrayList<EntityType<? extends AbstractBoat>>();types.add(EntityTypes.OAK_BOAT);types.add(EntityTypes.BAMBOO_RAFT);
for(var shape:CrewBoats.SHAPES)types.add(CrewBoats.variant("oak",shape.columns(),shape.rows()).type());
types.add(CrewBoats.variant("bamboo",3,3).type());types.add(EntityTypes.OAK_BOAT);types.add(EntityTypes.OAK_BOAT);
for(int index=0;index<types.size();index++){
var type=types.get(index);boolean familiar=index==types.size()-1,baby=index==types.size()-2;
int[] ids=server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.stopRiding();p.setPos(0,2,0);
var boat=type.create(p.level(),EntitySpawnReason.COMMAND);boat.setPos(0,1,0);p.level().addFreshEntity(boat);
Mob mob;
if(familiar){var pet=FamiliarService.TYPE.create(p.level(),EntitySpawnReason.COMMAND);pet.bind(p,new ItemStack(Items.CHICKEN_SPAWN_EGG));mob=pet;}
else{var chicken=EntityTypes.CHICKEN.create(p.level(),EntitySpawnReason.COMMAND);chicken.setBaby(baby);mob=chicken;}
mob.setPos(boat.position());mob.setNoAi(true);mob.getAttribute(Attributes.MAX_HEALTH).setBaseValue(40);mob.setHealth(40);p.level().addFreshEntity(mob);
boolean boarded=boat instanceof CrewBoat ? p.startRiding(boat,true,true)&&mob.startRiding(boat,true,true) : mob.startRiding(boat,true,true)&&p.startRiding(boat,true,true);
check(boarded,"Both passengers board "+type);
HeadEquipment.set(mob,new ItemStack(Items.CHEST));((Container)HeadBlockService.session(mob).entity).setItem(0,new ItemStack(Items.DIAMOND,7));
p.inventoryMenu.broadcastChanges();return new int[]{boat.getId(),mob.getId()};
});
c.waitFor(m->m.player.getVehicle()!=null&&m.player.getVehicle().getId()==ids[0]&&m.level.getEntity(ids[1])!=null&&m.level.getEntity(ids[1]).getVehicle()==m.player.getVehicle(),200);c.waitTicks(15);
aim(c,ids[1]);assertPick(c,ids[1]);
c.getInput().pressMouse(InputConstants.MOUSE_BUTTON_RIGHT);
c.waitFor(m->m.gui.screen() instanceof ContainerScreen,100);
server.runOnServer(s->check(s.getPlayerList().getPlayers().getFirst().containerMenu.getSlot(0).getItem().is(Items.DIAMOND),"Right click opens the seated mob's actual chest"));
c.runOnClient(m->m.player.closeContainer());c.waitTicks(8);
server.runOnServer(s->{var mob=mob(s,ids[1]);HeadEquipment.set(mob,new ItemStack(Items.DISPENSER));((Container)HeadBlockService.session(mob).entity).setItem(0,new ItemStack(Items.ARROW,4));});
c.waitTicks(8);aim(c,ids[1]);assertPick(c,ids[1]);c.getInput().pressMouse(InputConstants.MOUSE_BUTTON_LEFT);
server.waitFor(s->(familiar?mob(s,ids[1]).getHealth()==40:mob(s,ids[1]).getHealth()<40)&&((Container)HeadBlockService.session(mob(s,ids[1])).entity).getItem(0).getCount()==3,100);
server.runOnServer(s->{check(mob(s,ids[1]).getVehicle()!=null,"Hit does not unseat the animal");check(!p(s).level().getEntitiesOfClass(net.minecraft.world.entity.projectile.arrow.Arrow.class,new AABB(-12,-4,-12,12,10,12)).isEmpty(),"A real projectile is fired");});
c.runOnClient(m->{var other=EntityTypes.CHICKEN.create(m.level,EntitySpawnReason.COMMAND);check(!BoatPassengerInteractions.aboardTogether(m.player,other),"Unrelated mob excluded from exception");check(!BoatPassengerInteractions.aboardTogether(m.player,m.player),"Player seats excluded from exception");});
if(familiar)server.runOnServer(s->{
var pet=(FamiliarEntity)mob(s,ids[1]);check(!CarryService.pair(p(s),pet),"Seated familiar accepts taps");
var ammo=(Container)HeadBlockService.session(pet).entity;
pet.stopRiding();pet.setAttached(CarryService.CARRIED,true);check(pet.startRiding(p(s),true,true),"Familiar can be carried for protection check");
check(CarryService.pair(p(s),pet)&&CarryService.pair(pet,p(s)),"Head carry protection is symmetric and preserved");
pet.tap(p(s));check(pet.getHealth()==40&&ammo.getItem(0).getCount()==3,"Carried familiar remains protected from equipment pulses");
pet.setAttached(CarryService.CARRIED,false);
});
System.out.println("BOAT091_SEAT_PASS "+type+" familiar="+familiar+" baby="+baby);
server.runOnServer(s->{p(s).stopRiding();HeadEquipment.set(mob(s,ids[1]),ItemStack.EMPTY);mob(s,ids[1]).discard();p(s).level().getEntity(ids[0]).discard();});c.waitTicks(8);
}
System.out.println("BOAT091_PASS: vanilla boat/raft, every tiled shape, native targeting, right-click storage, left-click damage and real dispenser shots, baby/familiar passengers and preserved carry protection");
}
}
private static net.minecraft.server.level.ServerPlayer p(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static Mob mob(MinecraftServer s,int id){return (Mob)p(s).level().getEntity(id);}
private static void aim(ClientGameTestContext c,int id){var d=c.computeOnClient(m->m.level.getEntity(id).getBoundingBox().getCenter().subtract(m.player.getEyePosition()));c.getInput().lookAt((float)Math.toDegrees(Math.atan2(-d.x,d.z)),(float)-Math.toDegrees(Math.atan2(d.y,Math.sqrt(d.x*d.x+d.z*d.z))));c.waitTicks(4);}
private static void assertPick(ClientGameTestContext c,int id){c.runOnClient(m->{var h=m.player.raycastHitResult(1,m.player);check(h instanceof EntityHitResult e&&e.getEntity().getId()==id,"Native crosshair must pick seated animal: "+h+" eye="+m.player.getEyePosition()+" view="+m.player.getViewVector(1)+" mob="+m.level.getEntity(id).getBoundingBox()+" yaw="+m.player.getYRot()+" eligible="+BoatPassengerInteractions.aboardTogether(m.player,m.level.getEntity(id)));});}
private static void check(boolean value,String message){if(!value)throw new AssertionError(message);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
}
@@ -51,11 +51,11 @@ public final class Collections035ClientChecks implements FabricClientGameTest {
m.gui.setScreen(new InventoryScreen(m.player));});c.waitTicks(5);c.takeScreenshot("sanctuary-beta035-personal-catalogue");
hover(c,36);c.getInput().pressKey(InputConstants.KEY_U);c.waitFor(m->m.gui.screen() instanceof mezz.jei.gui.recipes.RecipesGui,60);
c.runOnClient(m->{m.gui.setScreen(new RecipeScreen(null));button(m.gui.screen(),"sanctuary.recipes.open").onPress(enter());});c.waitFor(m->m.gui.screen() instanceof mezz.jei.gui.recipes.RecipesGui,60);c.waitTicks(5);c.takeScreenshot("sanctuary-beta035-wood-recipes");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();s.getPlayerList().op(p.nameAndId());RecipeService.refresh(p);});c.waitFor(m->RecipeClient.operatorAllowed,120);
c.runOnClient(m->{require(!RecipeClient.operator,"Operator status alone does not enable full view");m.gui.setScreen(new RecipeScreen(null));RecipeClient.request("operator");});
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.runOnClient(m->{require(RecipeClient.operator,"Allow Commands enables the operator catalogue");m.gui.setScreen(new RecipeScreen(null));});
c.waitFor(m->RecipeClient.operator,120);c.waitTicks(3);c.takeScreenshot("sanctuary-beta035-operator-view");
c.runOnClient(m->require(SanctuaryJeiPlugin.runtime().getIngredientFilter().getFilteredItemStacks().stream().anyMatch(i->i.is(Items.DIAMOND_BLOCK)),"Explicit operator sees unknown items"));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();s.getPlayerList().deop(p.nameAndId());RecipeService.refresh(p);});
c.runOnClient(m->require(SanctuaryJeiPlugin.runtime().getIngredientFilter().getFilteredItemStacks().stream().anyMatch(i->i.is(Items.DIAMOND_BLOCK)),"Operator sees unknown items"));
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);c.runOnClient(m->require(SanctuaryJeiPlugin.runtime().getIngredientFilter().getFilteredItemStacks().stream().noneMatch(i->i.is(Items.DIAMOND_BLOCK)),"Permission revocation removes unknown items"));
// Use JEI's real transfer packet with ingredients available only in the final added row.
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);
@@ -0,0 +1,93 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.vertex.PoseStack;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.progression.SanctuaryGameplay;
import java.lang.reflect.Proxy;
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.Minecraft;
import net.minecraft.client.CameraType;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.state.*;
import net.minecraft.core.Direction;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.entity.CampfireBlockEntity;
/** Native renderer submissions and synchronized animation, in a disposable integrated world. */
public final class Cosmetic066ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(6);m.options.pauseOnLostFocus=false;});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Cosmetic066 "+UUID.randomUUID());s.setSeed("66");s.setAllowCommands(true);s.getGameRules().set(SanctuaryGameplay.ENABLED,false,null);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("minecraft:flat")).findFirst().orElseThrow());}).create()) {
world.getConnection().waitForChunksDownload();var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.getInventory().clearContent();s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");});
c.runOnClient(m->{m.options.setCameraType(CameraType.THIRD_PERSON_FRONT);m.options.fov().set(40);m.getWindow().setWindowed(1280,800);m.resizeGui();});c.getInput().lookAt(0,-12);
for(var item:List.of(Items.CHEST,Items.TRAPPED_CHEST,Items.ENDER_CHEST,Items.SHULKER_BOX)) {
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();HeadEquipment.set(p,new ItemStack(item));HeadBlockService.session(p).event(1,1);});
c.waitTicks(18);
c.takeScreenshot("cosmetic066-open-"+item);
c.runOnClient(m->{var h=head(m,m.player);var calls=draw(h);System.out.println("COSMETIC066_OPEN "+item+" "+calls);
check(calls.models()==1,"Exactly one animated container, no closed duplicate: "+item+" "+calls);
if(h.blockEntity instanceof ChestRenderState chest)check(chest.open>.7,"Synced chest lid opens");
else check(h.blockEntity instanceof ShulkerBoxRenderState box&&box.progress>.9,"Synced Shulker lid opens");
});
server.runOnServer(s->HeadBlockService.session(s.getPlayerList().getPlayers().getFirst()).event(1,0));c.waitTicks(18);
c.runOnClient(m->{var h=head(m,m.player);check(draw(h).models()==1,"One closed model");if(h.blockEntity instanceof ChestRenderState chest)check(chest.open<.05,"Chest closes");else check(((ShulkerBoxRenderState)h.blockEntity).progress<.05,"Shulker closes");});
}
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();HeadEquipment.set(p,new ItemStack(Items.BELL));HeadBlockService.session(p).event(1,Direction.NORTH.get3DDataValue());});
c.waitTicks(7);c.takeScreenshot("cosmetic066-bell-ringing");
c.runOnClient(m->{var h=head(m,m.player);var calls=draw(h);check(calls.models()==1&&calls.blocks==1,"One moving bell and its static support: "+calls);check(h.blockEntity instanceof BellRenderState bell&&bell.shakeDirection!=null&&bell.ticks>1,"Bell shakes from the server event");});
c.waitTicks(50);c.runOnClient(m->check(((BellRenderState)head(m,m.player).blockEntity).shakeDirection==null,"Bell settles normally"));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();HeadEquipment.set(p,new ItemStack(Items.CAMPFIRE));var session=HeadBlockService.session(p);HeadBlockService.scoped(session,()->{((CampfireBlockEntity)session.entity).placeFood(p.level(),p,new ItemStack(Items.BEEF));return null;});});
c.waitTicks(15);c.runOnClient(m->{var h=head(m,m.player);check(draw(h).blocks==1,"Campfire body remains");check(h.blockEntity instanceof CampfireRenderState f&&f.items.stream().anyMatch(i->!i.isEmpty()),"Campfire still renders synchronized food");});c.takeScreenshot("cosmetic066-campfire-food");
// The same renderer must retain partial static bodies and complete models that have no live renderer.
c.runOnClient(m->{
var original=HeadCosmeticService.appearance(m.player).copy();
for(var item:List.of(Items.ENCHANTING_TABLE,Items.LECTERN,Items.OAK_SIGN,Items.BANNER.white(),Items.PLAYER_HEAD,Items.BED.red(),Items.OAK_DOOR,Items.FURNACE,Items.OAK_SHELF)) {
m.player.setAttached(AccessoryService.APPEARANCE,new AccessoryService.Appearance(ItemStack.EMPTY,new ItemStack(item)));
var h=head(m,m.player);var calls=draw(h);System.out.println("COSMETIC066_RETAINED "+item+" "+calls);
check(calls.models()+calls.blocks>0,"Head visual retained: "+item);
if(item==Items.ENCHANTING_TABLE)check(calls.models()==1&&calls.blocks==1,"One book on enchanting table body");
if(item==Items.OAK_SIGN)check(calls.blocks==1&&calls.models()==0,"One sign board mesh");
if(item==Items.BANNER.white())check(Collections.frequency(calls.modelNames,"BannerModel")==1,"One banner support, with native fabric texture passes");
if(item==Items.PLAYER_HEAD)check(calls.models()==1,"One skull model");
if(item==Items.BED.red()||item==Items.OAK_DOOR)check(!h.second.isEmpty(),"Both bed/door halves retained");
}
int nextId=200000;
for(var type:List.of(EntityTypes.CHICKEN,EntityTypes.COW,EntityTypes.ZOMBIE))for(boolean baby:List.of(false,true)) {
var mob=(Mob)type.create(m.level,EntitySpawnReason.COMMAND);mob.setId(nextId++);mob.setPos(m.player.position());mob.setBaby(baby);
for(var item:List.of(Items.CHEST,Items.BELL)) {
mob.setAttached(AccessoryService.APPEARANCE,new AccessoryService.Appearance(ItemStack.EMPTY,new ItemStack(item)));
var h=head(m,mob);var calls=draw(h);check(calls.models()==1,"Mob hat has one model: "+type+" baby="+baby+" "+item+" "+calls);
if(item==Items.BELL)check(calls.blocks==1,"Mob bell keeps its support");
}
}
m.player.setAttached(AccessoryService.APPEARANCE,new AccessoryService.Appearance(ItemStack.EMPTY,original));
});
System.out.println("COSMETIC066_PASS: native submissions, four synced container animations, bell ring/settle, static supports, food and twelve mob hat cases");
}
}
private static HeadCosmeticState head(Minecraft m,LivingEntity entity) {
var state=m.getEntityRenderDispatcher().getRenderer(entity).createRenderState(entity,1);
return entity==m.player?((HeadCosmeticState.Access)state).sanctuary$headCosmetic():((MobHeadState.Access)state).sanctuary$mobHat().head;
}
private static final class Calls {
final List<String> modelNames=new ArrayList<>();int blocks;
int models(){return modelNames.size();}
public String toString(){return "models="+modelNames+", block meshes="+blocks;}
}
private static Calls draw(HeadCosmeticState head) {
var calls=new Calls();
SubmitNodeCollector collector=(SubmitNodeCollector)Proxy.newProxyInstance(SubmitNodeCollector.class.getClassLoader(),new Class<?>[]{SubmitNodeCollector.class},(proxy,method,args)->{
if(method.getName().equals("order"))return proxy;
if(method.getName().equals("submitModel"))calls.modelNames.add(args[0].getClass().getSimpleName());
if(method.getName().equals("submitBlockModel"))calls.blocks++;
return null;
});
HeadCosmeticLayer.submitAtHead(new PoseStack(),collector,15728880,0,head);return calls;
}
private static void check(boolean yes,String message){if(!yes)throw new AssertionError(message);}
}
@@ -0,0 +1,70 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.cosmetics.*;
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.gui.components.Button;
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;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.level.gamerules.GameRules;
/** Creation toggles and saved-world reopen, using ordinary vanilla presets and actual admission. */
public final class Gameplay063ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var observing=new AtomicBoolean(true);var hello=new AtomicInteger();var lastHello=new AtomicReference<HelloWorldScreen>();
ClientTickEvents.END_CLIENT_TICK.register(m->{if(observing.get()&&m.gui.screen() instanceof HelloWorldScreen s){
if(lastHello.getAndSet(s)==s)return;
hello.incrementAndGet();IntroClient.setQuickTestIntro(true);Starter039TestSupport.selectFirst(s);
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
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());
}});
c.runOnClient(m->{m.options.renderDistance().set(4);m.options.simulationDistance().set(4);m.options.pauseOnLostFocus=false;});
try{
for(String preset:List.of("minecraft:flat","minecraft:normal"))for(boolean enabled:List.of(true,false)){
int before=hello.get();net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
var identity=new AtomicReference<UUID>();
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Gameplay063 "+UUID.randomUUID());s.setSeed("63");s.setAllowCommands(true);s.setGenerateStructures(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals(preset)).findFirst().orElseThrow());s.getGameRules().set(SanctuaryGameplay.ENABLED,enabled,null);}).create()){
saved=world.getWorldSave();c.waitFor(m->m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),2400);c.waitTicks(25);
check((hello.get()>before)==enabled,"Hello World only when enabled: "+preset+" "+enabled);
check(c.computeOnClient(m->ProgressionClient.active())==enabled,"Client knows selected mode");
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;
check(SanctuaryGameplay.supported(s)==enabled&&(ProgressionService.session(s)!=null)==enabled,"Server uses the creation choice");
check((s.overworld().getChunkSource().getGenerator() instanceof net.minecraft.world.level.levelgen.FlatLevelSource)==preset.endsWith("flat"),"Selected native generator preserved");
try{fr.koka.sanctuary.expansion.ExpansionRuntime.session(s.overworld());throw new AssertionError("Native terrain must not open an expansion session");}
catch(IllegalArgumentException expected){check(expected.getMessage().equals("laboratory_required"),"No island planning or expansion system on native terrain");}
check(SanctuaryInventoryCapacity.unlockedRows(p)==(enabled?1:4),"Correct inventory capacity");
check(p.getAttributeValue(Attributes.MAX_HEALTH)==(enabled?6:20),"Correct initial health");
check(fr.koka.sanctuary.realtime.RealtimeService.active(s.overworld())==enabled&&fr.koka.sanctuary.weather.WeatherService.active(s.overworld())==enabled,"Clock and weather follow gameplay choice");
if(enabled){check(FamiliarService.active(p)!=null,"Starter familiar enters native terrain");identity.set(fr.koka.sanctuary.familiar.FamiliarBattle.bond(p).id());}
check(s.getGlobalGameRules().get(SanctuaryGameplay.ENABLED)==enabled,"Rule retained");
s.overworld().getGameRules().set(SanctuaryGameplay.ENABLED,!enabled,s);check(s.overworld().getGameRules().get(SanctuaryGameplay.ENABLED)==enabled,"Live dimension rule flip rejected before mutation");
s.getGlobalGameRules().set(SanctuaryGameplay.ENABLED,!enabled,s);check(s.getGlobalGameRules().get(SanctuaryGameplay.ENABLED)==enabled,"Live global rule flip rejected");
try{s.getCommands().getDispatcher().execute("gamerule sanctuary:use_sanctuary "+!enabled,s.createCommandSourceStack());throw new AssertionError("Command must be rejected");}catch(com.mojang.brigadier.exceptions.CommandSyntaxException expected){check(expected.getRawMessage().getString().contains("Sanctuary"),"Command explains creation-only choice");}
s.overworld().getGameRules().set(GameRules.KEEP_INVENTORY,true,s);check(s.overworld().getGameRules().get(GameRules.KEEP_INVENTORY),"Other game rules remain editable");
});
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),2400);c.waitTicks(20);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(s.getWorldData().overworldData().isInitialized(),"Reopened saved world");check(SanctuaryGameplay.supported(s)==enabled&&(ProgressionService.session(s)!=null)==enabled,"Mode survives reopening");
if(enabled)check(identity.get().equals(fr.koka.sanctuary.familiar.FamiliarBattle.bond(p).id()),"Starter identity preserved across reopen");
});
check(hello.get()==before+(enabled?1:0),"Reopen does not repeat character creation");
}
System.out.println("GAMEPLAY063_CASE_PASS: "+preset+" enabled="+enabled+" creation and reopen");
}
for(String locale:List.of("fr_fr","en_us"))c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());check(!Component.translatable(SanctuaryGameplay.ENABLED.getDescriptionId()).getString().contains("gamerule."),"Native gamerule name localized: "+locale);check(!Component.translatable(SanctuaryGameplay.ENABLED.getDescriptionId()+".description").getString().contains("gamerule."),"Native tooltip localized: "+locale);});
System.out.println("GAMEPLAY063_PASS");
}finally{observing.set(false);}
}
private static KeyEvent enter(){return new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,94 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.multiblock.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.network.protocol.game.ServerboundPickItemFromBlockPacket;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.piston.*;
import net.minecraft.world.phys.*;
public final class Pick092ClientChecks implements FabricClientGameTest {
private static final BlockPos C=new BlockPos(0,50,0);
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{
if(m.gui.screen() instanceof IntroScreen s)s.onClose();
if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen s){Starter039TestSupport.selectFirst(s);
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}
});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Pick092 "+UUID.randomUUID());s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
var server=world.getServer();server.runOnServer(s->{creative(p(s));verifyStates(p(s).level());});c.runOnClient(m->verifyStates(m.level));
for(boolean sticky:new boolean[]{false,true})for(var f:Direction.values()){
Item item=sticky?Items.STICKY_PISTON:Items.PISTON;
server.runOnServer(s->{var l=p(s).level();for(var facing:Direction.values())for(boolean glue:new boolean[]{false,true})SuperPistons.dismantle(l,C,facing,glue);
for(int x=-6;x<=6;x++)for(int y=-6;y<=6;y++)for(int z=-6;z<=6;z++)l.setBlockAndUpdate(C.offset(x,y,z),Blocks.AIR.defaultBlockState());
for(int i=0;i<9;i++)l.setBlockAndUpdate(SuperPistons.tile(C,f,i),(sticky?Blocks.STICKY_PISTON:Blocks.PISTON).defaultBlockState().setValue(PistonBaseBlock.FACING,f));
check(SuperPistons.assemble(l,C,f,sticky),"Assemble real piston");
});c.waitTicks(5);
pick(c,server,C,f,item,false);
// Ctrl-pick uses the actual server handler; its result must still be an ordinary piston.
server.runOnServer(s->{p(s).getInventory().clearContent();p(s).connection.handlePickItemFromBlock(new ServerboundPickItemFromBlockPacket(C,true));check(ItemStack.matches(p(s).getMainHandItem(),new ItemStack(item)),"Ctrl-pick excludes controller data: "+p(s).getMainHandItem()+" components="+p(s).getMainHandItem().getComponents()+" eye="+p(s).getEyePosition());});
server.runOnServer(s->p(s).level().setBlockAndUpdate(SuperPistons.tile(C,f,0).relative(f,-1),Blocks.REDSTONE_BLOCK.defaultBlockState()));
server.waitFor(s->p(s).level().getBlockEntity(C) instanceof SuperPistonController be&&be.stroke==3&&be.pending.isEmpty(),200);c.waitTicks(5);
pick(c,server,C.relative(f,3),f,item,false);
pick(c,server,C.relative(f),SuperPistons.u(f),item,false);
pick(c,server,C.relative(f,2),SuperPistons.u(f),item,false);
if(f==Direction.NORTH){
server.runOnServer(s->{var p=p(s);p.setGameMode(GameType.SURVIVAL);p.getAbilities().mayfly=true;p.getAbilities().flying=true;p.onUpdateAbilities();});
pick(c,server,C.relative(f,3),f,item,true);
server.runOnServer(s->{p(s).getInventory().clearContent();p(s).inventoryMenu.broadcastChanges();});
c.waitFor(m->m.player.getMainHandItem().isEmpty(),100);c.getInput().pressMouse(InputConstants.MOUSE_BUTTON_MIDDLE);c.waitTicks(8);
server.runOnServer(s->{check(p(s).getInventory().isEmpty(),"Survival pick creates no item");creative(p(s));});
}
System.out.println("PICK092_DIRECTION_PASS sticky="+sticky+" facing="+f);
}
server.runOnServer(s->moving(p(s)));
System.out.println("PICK092_PASS: 864 states on client/server; real middle clicks for bases, heads, both rods, six directions and variants; survival and moving/Ctrl-pick");
}finally{intro.set(false);}
}
private static void pick(ClientGameTestContext c,TestServerContext server,BlockPos pos,Direction from,Item item,boolean survival){
server.runOnServer(s->{var p=p(s);p.getInventory().clearContent();p.getInventory().setSelectedSlot(0);if(survival)p.getInventory().setItem(4,new ItemStack(item,5));p.inventoryMenu.broadcastChanges();
p.teleportTo(pos.getX()+.5+from.getStepX()*2.5,pos.getY()+.5+from.getStepY()*2.5-p.getEyeHeight(),pos.getZ()+.5+from.getStepZ()*2.5);p.setDeltaMovement(Vec3.ZERO);p.getAbilities().flying=true;p.onUpdateAbilities();
});c.waitFor(m->m.player.getMainHandItem().isEmpty(),100);c.waitTicks(5);c.runOnClient(m->{m.player.getAbilities().flying=true;m.player.setDeltaMovement(Vec3.ZERO);});c.getInput().lookAt(pos);c.waitTicks(4);
c.runOnClient(m->{var hit=m.player.raycastHitResult(1,m.player);check(hit instanceof BlockHitResult b&&b.getType()==HitResult.Type.BLOCK&&b.getBlockPos().equals(pos),"Native cursor targets the requested part: "+pos+" got "+hit.getLocation());});
c.getInput().pressMouse(InputConstants.MOUSE_BUTTON_MIDDLE);
c.waitFor(m->m.player.getMainHandItem().is(item),100);
server.runOnServer(s->check(ItemStack.matches(p(s).getMainHandItem(),new ItemStack(item,survival?5:1)),"Exact source item and count, no technical data"));
}
private static void verifyStates(LevelReader l){int count=0;for(var b:List.of(SuperPistons.NORMAL,SuperPistons.GLUANT,SuperPistons.HEAD,SuperPistons.ROD))for(var s:b.getStateDefinition().getPossibleStates()){
boolean sticky=s.getBlock() instanceof SuperPistonBlocks.Base base?base.sticky:s.getValue(SuperPistonBlocks.STICKY);
for(boolean data:new boolean[]{false,true})check(ItemStack.matches(s.getCloneItemStack(l,C,data),new ItemStack(sticky?Items.STICKY_PISTON:Items.PISTON)),"Source for "+s);count++;
}check(count==864,"Every technical state checked");}
private static void moving(ServerPlayer p){var l=p.level();var pos=C.offset(5,0,0);p.teleportTo(pos.getX()+.5,pos.getY(),pos.getZ()+2);creative(p);
for(boolean sticky:new boolean[]{false,true})for(boolean extending:new boolean[]{false,true}){
var carried=extending?SuperPistons.headForBase(SuperPistons.base(Direction.NORTH,4,sticky,true),2):SuperPistons.base(Direction.NORTH,4,sticky,true);
var state=Blocks.MOVING_PISTON.defaultBlockState().setValue(MovingPistonBlock.FACING,Direction.NORTH);l.setBlock(pos,state,324);l.setBlockEntity(MovingPistonBlock.newMovingBlockEntity(pos,state,carried,Direction.NORTH,extending,true));
p.getInventory().clearContent();p.connection.handlePickItemFromBlock(new ServerboundPickItemFromBlockPacket(pos,true));check(ItemStack.matches(p.getMainHandItem(),new ItemStack(sticky?Items.STICKY_PISTON:Items.PISTON)),"Moving source picks ordinary piston without motion NBT");
l.removeBlockEntity(pos);
}
var state=Blocks.MOVING_PISTON.defaultBlockState();l.setBlockEntity(MovingPistonBlock.newMovingBlockEntity(pos,state,Blocks.STONE.defaultBlockState(),Direction.NORTH,true,false));check(state.getCloneItemStack(l,pos,false).isEmpty(),"Vanilla moving cargo behaviour unchanged");l.removeBlockEntity(pos);l.setBlock(pos,Blocks.AIR.defaultBlockState(),324);
for(var block:List.of(Blocks.FURNACE,Blocks.BARREL))check(block.defaultBlockState().getCloneItemStack(l,pos,false).is(block.asItem()),"Other multiblock source blocks still pick normally");
}
private static void creative(ServerPlayer p){p.setGameMode(GameType.CREATIVE);p.setNoGravity(true);p.getAbilities().flying=true;p.onUpdateAbilities();}
private static ServerPlayer p(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static void check(boolean v,String message){if(!v)throw new AssertionError(message);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
}
@@ -0,0 +1,129 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.inventory.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.CustomData;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
/** Native solar engine, live equipment packets and familiar combat health on a fresh flat world. */
public final class Sunlight062ClientChecks 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);
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 world=c.worldBuilder().adjustSettings(s->{s.setName("Sunlight062 "+UUID.randomUUID());s.setSeed("62");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()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&m.gui.screen()==null&&ProgressionClient.active()&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.getAbilities().invulnerable=true;p.getInventory().clearContent();
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);
command(s,"sanctuary time vanilla");command(s,"sanctuary weather vanilla");command(s,"weather clear 100000");command(s,"time set noon");
p.teleportTo(.5,1,.5);for(int x=-9;x<=9;x++)for(int z=-9;z<=9;z++)for(int y=0;y<=7;y++)p.level().setBlockAndUpdate(new BlockPos(x,y,z),(y==0?Blocks.STONE:Blocks.AIR).defaultBlockState());
});c.waitTicks(30);
server.runOnServer(s->{species(player(s));nativeHats(player(s));command(s,"time set midnight");});c.waitTicks(5);
server.runOnServer(s->{var p=player(s);var pet=probe(p,Items.ZOMBIE_SPAWN_EGG);expose(pet);check(!pet.isOnFire(),"Night prevents ignition");pet.discard();
fresh(p,Items.ZOMBIE_SPAWN_EGG);p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.DIAMOND));p.inventoryMenu.broadcastChanges();});c.waitTicks(25);
int id=server.computeOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet!=null,"Equipped zombie egg spawns");pet.setNoAi(true);pet.setPos(p.position().add(0,0,2));pet.setDeltaMovement(Vec3.ZERO);return pet.getId();});
c.waitFor(m->m.level.getEntity(id)!=null&&m.player.getMainHandItem().is(Items.DIAMOND),100);
c.getInput().holdKey(o->o.keyShift);c.waitTicks(5);click(c,id);c.waitTicks(8);c.getInput().releaseKey(o->o.keyShift);
float healthy=server.computeOnServer(s->{var pet=FamiliarService.active(player(s));check(HeadEquipment.get(pet).is(Items.DIAMOND),"Real sneak-click equips a non-block sun hat");command(s,"time set noon");return pet.getHealth();});
c.waitTicks(100);server.runOnServer(s->{var pet=FamiliarService.active(player(s));check(!pet.isOnFire()&&pet.getHealth()==healthy,"Hat protects familiar during real server ticks");expose(pet);check(!pet.isOnFire(),"Hat also withstands a long solar exposure");});
c.runOnClient(m->{var pet=(FamiliarEntity)m.level.getEntity(id);var state=(FamiliarRenderer.State)m.getEntityRenderDispatcher().getRenderer(pet).createRenderState(pet,1);check(((MobHeadState.Access)state.appearance).sanctuary$mobHat().active,"Synced hat renders on the familiar model");});
c.getInput().holdKey(o->o.keyShift);c.waitTicks(5);click(c,id);c.waitTicks(8);c.getInput().releaseKey(o->o.keyShift);
server.runOnServer(s->{var pet=FamiliarService.active(player(s));check(HeadEquipment.get(pet).isEmpty(),"Empty-hand sneak-click removes hat");expose(pet);check(pet.isOnFire(),"Removing hat exposes familiar to the sun again");});
c.waitTicks(45);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet.getHealth()<healthy&&FamiliarBattle.bond(p).health()==pet.getHealth(),"Native on-fire ticks lower persisted familiar health");});
c.waitFor(m->m.level.getEntity(id)!=null&&m.level.getEntity(id).isOnFire(),100);
c.runOnClient(m->{var pet=(FamiliarEntity)m.level.getEntity(id);var state=(FamiliarRenderer.State)m.getEntityRenderDispatcher().getRenderer(pet).createRenderState(pet,1);check(state.displayFireAnimation&&state.boundingBoxHeight==pet.visualHeight(),"Synced native fire follows visible baby size");});
c.getInput().lookAt(0,12);c.takeScreenshot("sanctuary-beta062-sunburn");
// A hat prevents new ignition; it must never grant blanket fire immunity.
server.runOnServer(s->{var pet=FamiliarService.active(player(s));HeadEquipment.set(pet,new ItemStack(Items.STONE));pet.clearFire();pet.igniteForSeconds(2);check(pet.isOnFire(),"A hat does not extinguish an external flame");});c.waitTicks(45);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(!pet.isOnFire(),"With hat, previous fire expires without solar reignition");
HeadEquipment.set(pet,ItemStack.EMPTY);roof(p,true);pet.clearFire();});c.waitTicks(8);
server.runOnServer(s->{var pet=FamiliarService.active(player(s));expose(pet);check(!pet.isOnFire(),"Solid roof prevents ignition");roof(player(s),false);
var p=player(s);for(int x=-1;x<=1;x++)for(int z=1;z<=3;z++)p.level().setBlockAndUpdate(new BlockPos(x,1,z),Blocks.WATER.defaultBlockState());pet.igniteForSeconds(8);});c.waitTicks(15);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet.isInWater()&&!pet.isOnFire(),"Real water extinguishes existing familiar fire");expose(pet);check(!pet.isOnFire(),"Immersion prevents solar ignition");
for(int x=-9;x<=9;x++)for(int z=-9;z<=9;z++)for(int y=1;y<=2;y++)p.level().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());
pet.setPos(.5,1,2.5);pet.setDeltaMovement(Vec3.ZERO);HeadEquipment.set(pet,new ItemStack(Items.STONE));command(s,"weather rain 100000");});c.waitTicks(130);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);HeadEquipment.set(pet,ItemStack.EMPTY);pet.clearFire();check(pet.isInWaterOrRain(),"Rain fixture actually wets the familiar");expose(pet);check(!pet.isOnFire(),"Rain protects familiar under open sky");HeadEquipment.set(pet,new ItemStack(Items.STONE));command(s,"weather clear 100000");});c.waitTicks(130);
for(String locale:List.of("fr_fr","en_us"))c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());check(!Component.translatable("sanctuary.familiar.sunlight.hint").getString().contains("sanctuary."),"Sunlight advice localized in "+locale);});
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);var egg=AccessoryAccess.of(p).getItem(AccessoryContainer.FAMILIAR).copy();
HeadEquipment.set(pet,ItemStack.EMPTY);pet.clearFire();pet.setHealth(.5F);expose(pet);check(pet.isOnFire(),"Bare familiar ignites after clear weather resumes");
// A final native baseTick delivers on-fire damage, not a direct test-only damage call.
pet.setRemainingFireTicks(160);pet.baseTick();check(pet.isRemoved()&&FamiliarBattle.bond(p).knockedOutUntil()>FamiliarBattle.now(),"Sunfire causes ordinary familiar KO");
check(ItemStack.isSameItemSameComponents(egg,AccessoryAccess.of(p).getItem(AccessoryContainer.FAMILIAR)),"KO preserves the equipped egg and identity");
command(s,"time set midnight");duelSunfire(p);
});
System.out.println("SUNLIGHT062_PASS: species, real hat packets, native sunlight/fire/health/KO, roofs, night, water, rain, flame size, duels and FR/EN");
}finally{onboarding.set(false);c.getInput().releaseKey(o->o.keyShift);}
}
private static void species(ServerPlayer p){
var expected=Set.of("zombie","zombie_villager","drowned","zombie_horse","zombie_nautilus","skeleton","stray","bogged","phantom");var actual=new HashSet<String>();int count=0;
for(var item:BuiltInRegistries.ITEM)if(item instanceof SpawnEggItem){
var stack=new ItemStack(item);String name=BuiltInRegistries.ENTITY_TYPE.getKey(SpawnEggItem.getType(stack)).getPath();
var pet=probe(p,item);expose(pet);if(pet.isOnFire())actual.add(name);
check(pet.isOnFire()==expected.contains(name),"Native daylight species: "+name);pet.discard();count++;
}
check(actual.equals(expected),"All nine sun-vulnerable egg species tested; fire-immune wither skeleton exempt");
System.out.println("SUNLIGHT062_SPECIES_PASS: "+count+" spawn eggs, burning="+actual);
}
private static void nativeHats(ServerPlayer p){
var mob=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);mob.setPos(.5,1,2.5);mob.setNoAi(true);expose(mob);check(mob.isOnFire(),"Native bare zombie burns");
for(var item:List.of(Items.STONE,Items.DIAMOND,Items.STICK,Items.TORCH)){
mob.clearFire();HeadEquipment.set(mob,new ItemStack(item));expose(mob);check(!mob.isOnFire(),"Any Sanctuary head item shades a normal mob: "+item);
}
HeadEquipment.set(mob,ItemStack.EMPTY);var helmet=new ItemStack(Items.DIAMOND_HELMET);mob.setItemSlot(EquipmentSlot.HEAD,helmet);mob.clearFire();expose(mob);
check(!mob.isOnFire()&&helmet.getDamageValue()>0,"Native head armour still protects and wears");
mob.setItemSlot(EquipmentSlot.HEAD,ItemStack.EMPTY);expose(mob);check(mob.isOnFire(),"Uncovered native mob burns again");mob.discard();
}
private static FamiliarEntity probe(ServerPlayer p,Item item){var pet=FamiliarService.TYPE.create(p.level(),EntitySpawnReason.COMMAND);pet.bind(p,egg(item));pet.setPos(.5,1,2.5);pet.setNoAi(true);return pet;}
private static void expose(Mob mob){for(int i=0;i<400;i++)mob.aiStep();}
private static void roof(ServerPlayer p,boolean present){for(int x=-3;x<=3;x++)for(int z=-1;z<=5;z++)p.level().setBlockAndUpdate(new BlockPos(x,4,z),(present?Blocks.STONE:Blocks.AIR).defaultBlockState());}
private static void fresh(ServerPlayer p,Item item){FamiliarBattle.cancel(p);p.setAttached(FamiliarBattle.COMBAT_LOCK,0L);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg(item));p.tickCount+=10-p.tickCount%10;FamiliarService.tick(p);}
private static void duelSunfire(ServerPlayer p){
var s=p.level().getServer();fresh(p,Items.ZOMBIE_SPAWN_EGG);
var profile=new com.mojang.authlib.GameProfile(UUID.randomUUID(),"Shade062");
var q=new ServerPlayer(s,p.level(),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);
q.connection=new net.minecraft.server.network.ServerGamePacketListenerImpl(s,connection,q,net.minecraft.server.network.CommonListenerCookie.createInitial(profile,false));q.setPos(p.position().add(0,0,4));
q.setAttached(fr.koka.sanctuary.progression.ProgressionService.RECORD,fr.koka.sanctuary.progression.ProgressRecord.INITIAL.json());fr.koka.sanctuary.progression.ProgressEffects.apply(q,fr.koka.sanctuary.progression.ProgressRecord.INITIAL);fresh(q,Items.COW_SPAWN_EGG);
check(FamiliarDuels.invite(p,q).equals("duel.sent"),"Sunlight duel accepted");request(q,"accept");request(p,"ready");request(q,"ready");
var match=FamiliarDuels.match(p);check(match.running(),"Sunlight duel started");match.phase=FamiliarDuels.Phase.FIGHT;
var pet=FamiliarService.active(p);float health=pet.getHealth();
pet.hurtServer(p.level(),p.damageSources().playerAttack(q),2);check(pet.getHealth()==health,"Player interference is still blocked in a duel");
pet.hurtServer(p.level(),p.damageSources().generic(),2);check(pet.getHealth()==health,"Unrelated environmental damage is still blocked in a duel");
pet.setHealth(.5F);pet.setRemainingFireTicks(160);pet.baseTick();check(pet.isRemoved()&&FamiliarDuels.match(p)==null&&FamiliarBattle.bond(p).knockedOutUntil()>FamiliarBattle.now(),"On-fire KO finishes a duel normally");
AccessoryAccess.of(q).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(q);q.discard();
}
private static void request(ServerPlayer p,String action){p.tickCount+=4;var m=FamiliarDuels.match(p);FamiliarDuels.request(p,m.id.toString(),m.revision,action,-1,0);}
private static ItemStack egg(Item item){var egg=new ItemStack(item);var data=new CompoundTag();data.putInt("schema",1);data.putInt("per_mille",1000);CustomData.update(DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,data));return egg;}
private static void command(MinecraftServer s,String cmd){s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),cmd);}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static KeyEvent enter(){return new KeyEvent(com.mojang.blaze3d.platform.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);});}
private static void check(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -88,7 +88,9 @@ public final class BoatsHead049GameTests {
h.assertTrue(primary.size()==v.tiles(),"Primary places occupy every module before secondaries");
if(v.columns()!=v.rows()){
b.setYRot(0);double x=b.getBoundingBox().getXsize(),z=b.getBoundingBox().getZsize();
h.assertTrue((x>z)==(v.columns()>v.rows()),"Dimensions distinguish transposed recipes");
double expectedX=v.raft()?v.columns()*1.25:v.columns()+.25;
double expectedZ=v.rows()*1.75+(v.raft()?0:.25);
h.assertTrue(Math.abs(x-expectedX)<.0001&&Math.abs(z-expectedZ)<.0001,"Bounds cover tiled hull and exterior rails");
b.setYRot(90);h.assertTrue(Math.abs(x-b.getBoundingBox().getZsize())<.0001&&Math.abs(z-b.getBoundingBox().getXsize())<.0001,"Collision bounds follow orientation");
var e=FlightBalance.Engines.NONE.add(FlightBalance.Kind.ALLAY,false);
h.assertTrue(FlightBalance.profile(e,2,v.columns(),v.rows()).equals(FlightBalance.profile(e,2,v.rows(),v.columns())),"Transpose has identical engine balance");
@@ -36,7 +36,7 @@ public final class Building009GameTests {
h.assertTrue(ProgressEffects.get(p).building()==rank && BuildingLimits.diameter(rank)==1+2*rank,"Each rank grows the diameter");
}
h.assertTrue(p.experienceLevel==0 && !ProgressionService.purchase(p,"building",7),"111 levels pay seven ranks, no eighth purchase");
h.assertTrue(p.blockInteractionRange()==reach && p.entityInteractionRange()==attack,"Neither block nor attack reach is changed");
h.assertTrue(p.blockInteractionRange()==reach && p.entityInteractionRange()==attack,"Native block interaction and attack attributes are unchanged; placement has its own skill range");
for (Direction face:Direction.values()) for (Direction look:List.of(Direction.NORTH,Direction.EAST,Direction.SOUTH,Direction.WEST)) {
var plane=BuildPlane.positions(BlockPos.ZERO,face,look,7);
h.assertTrue(plane.size()==225 && new HashSet<>(plane).size()==225 && plane.getFirst().equals(BlockPos.ZERO),"Every orientation makes 225 unique positions, centre first");
@@ -33,7 +33,7 @@ public final class Mining008GameTests {
@GameTest
public void purchaseRanksAndNativeSpeed(GameTestHelper h) {
var p=player(h,0);p.giveExperienceLevels(111);
h.assertTrue(p.getAttributeValue(Attributes.BLOCK_BREAK_SPEED)==1,"Rank zero keeps vanilla mining speed");
h.assertTrue(p.getAttributeValue(Attributes.BLOCK_BREAK_SPEED)==.75,"Rank zero starts at 75 percent of vanilla mining speed");
var other=net.minecraft.resources.Identifier.fromNamespaceAndPath("test","other_mining_modifier");
p.getAttribute(Attributes.BLOCK_BREAK_SPEED).addTransientModifier(new AttributeModifier(other,.5,AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL));
for (int i=1;i<=7;i++) {
@@ -0,0 +1,123 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.plans.*;
import java.io.IOException;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.minecraft.core.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.*;
import net.minecraft.world.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
public final class Plans072GameTests {
private static final Set<UUID> DENIED=new HashSet<>();
static { UseBlockCallback.EVENT.register((p,l,h,hit)->DENIED.contains(p.getUUID())?InteractionResult.FAIL:InteractionResult.PASS); }
@GameTest public void creativePasteAndSurvivalAuthority(GameTestHelper h){
var player=Progression003GameTests.player(h,GameType.SURVIVAL);player.setPos(h.absoluteVec(new net.minecraft.world.phys.Vec3(-4,4,-4)));
var origin=h.absolutePos(new BlockPos(1,4,1));var plan=fixture();
for(var c:plan.cells())h.getLevel().setBlockAndUpdate(origin.offset(c.pos()),Blocks.AIR.defaultBlockState());
h.assertTrue(PlanService.paste(player,plan,origin).equals("creative_only"),"Survival operator cannot paste");
h.assertTrue(h.getLevel().getBlockState(origin).isAir(),"Survival never modifies the world");
// GameTest's mock isCreative() is fixed to its creation mode.
var creative=Progression003GameTests.player(h,GameType.CREATIVE);creative.setPos(h.absoluteVec(new net.minecraft.world.phys.Vec3(-4,4,-4)));
String result=PlanService.paste(creative,plan,origin);
h.assertTrue(result.equals("placed"),"Creative paste succeeds in a loaded clear area: "+result);
for(var c:plan.cells())h.assertTrue(h.getLevel().getBlockState(origin.offset(c.pos())).getBlock()==c.state().getBlock(),"Native world contains expected blocks");
h.assertTrue(PlanService.paste(creative,plan,origin).equals("placed"),"Already matching blocks are not replaced");
h.succeed();
}
@GameTest public void preflightPreservesOccupiedBlocksAndPermissions(GameTestHelper h){
var player=Progression003GameTests.player(h,GameType.CREATIVE);player.setPos(h.absoluteVec(new net.minecraft.world.phys.Vec3(-4,4,-4)));
var origin=h.absolutePos(new BlockPos(1,4,1));var plan=fixture();
for(var c:plan.cells())h.getLevel().setBlockAndUpdate(origin.offset(c.pos()),Blocks.AIR.defaultBlockState());
var conflict=origin.offset(plan.cells().getLast().pos());h.getLevel().setBlockAndUpdate(conflict,Blocks.CHEST.defaultBlockState());
var chest=h.getLevel().getBlockEntity(conflict);
h.assertTrue(PlanService.paste(player,plan,origin).equals("occupied"),"One conflict rejects the complete paste");
h.assertTrue(h.getLevel().getBlockState(origin).isAir()&&h.getLevel().getBlockEntity(conflict)==chest,"No partial placement and original chest retained");
h.getLevel().setBlockAndUpdate(conflict,Blocks.AIR.defaultBlockState());DENIED.add(player.getUUID());
try{h.assertTrue(PlanService.paste(player,plan,origin).equals("denied"),"Fabric permission callback vetoes paste");}
finally{DENIED.remove(player.getUUID());}
for(var c:plan.cells())h.assertTrue(h.getLevel().getBlockState(origin.offset(c.pos())).isAir(),"Veto leaves all destinations empty");
var restricted=new BuildPlan("Restricted",1,1,1,List.of(new BuildPlan.Cell(BlockPos.ZERO,Blocks.COMMAND_BLOCK.defaultBlockState())));
h.assertTrue(PlanService.paste(player,restricted,origin).equals("restricted_block"),"No command block through plan import");
h.assertTrue(PlanService.paste(player,plan,origin.offset(1000,0,0)).equals("distance"),"Far imports cannot generate chunks");
h.succeed();
}
@GameTest public void litematicCrossWordPaletteAndNegativeRegions(GameTestHelper h) throws Exception {
var cells=new ArrayList<BuildPlan.Cell>();
var materials=new ArrayList<Block>();
for(var dye:net.minecraft.world.item.DyeColor.values())materials.add(BuiltInRegistries.BLOCK.getValue(net.minecraft.resources.Identifier.withDefaultNamespace(dye.getName()+"_wool")));
materials.add(Blocks.STONE);materials.add(Blocks.BRICKS);
for(int x=0;x<31;x++)cells.add(new BuildPlan.Cell(new BlockPos(x,0,0),materials.get(x%materials.size()).defaultBlockState()));
var input=new BuildPlan("Palette",31,1,1,cells);var encoded=SchematicIO.encode(input);var output=SchematicIO.decode(encoded,"Palette");
h.assertTrue(output.equals(input),"Blockstates survive 5-bit packed palette boundaries");
// Independent 5-bit witness: index 12 straddles bit 60 of the first long.
long[] words={17L<<60,1};h.assertTrue(SchematicIO.unpack(words,5,12)==17,"Bitstream crosses a 64-bit word without padding");
var region=encoded.getCompoundOrEmpty("Regions").getCompoundOrEmpty("Main");
var size=region.getCompoundOrEmpty("Size");size.putInt("x",-31);region.put("Size",size);
var origin=region.getCompoundOrEmpty("Position");origin.putInt("x",50);region.put("Position",origin);
h.assertTrue(SchematicIO.decode(encoded,"Palette").equals(input),"Negative region size moves the minimum, does not reverse indices");
var dir=java.nio.file.Files.createTempDirectory("sanctuary-plan072-");
try{
var file=SchematicIO.write(input,dir);h.assertTrue(SchematicIO.read(file).equals(input),"Compressed disk export opens as a plan");
var again=SchematicIO.write(input,dir);h.assertTrue(!file.equals(again),"Repeated export preserves earlier file");
java.nio.file.Files.delete(file);java.nio.file.Files.delete(again);
}finally{java.nio.file.Files.delete(dir);}
h.succeed();
}
@GameTest public void sharedFormatsAndStrictInvalidInput(GameTestHelper h) throws Exception {
for(int version:List.of(2,3)){
var root=new CompoundTag();root.putInt("Version",version);root.putInt("DataVersion",0);root.putShort("Width",(short)2);root.putShort("Height",(short)1);root.putShort("Length",(short)1);
var palette=new CompoundTag();palette.putInt("minecraft:stone",0);palette.putInt("minecraft:oak_stairs[facing=east,half=top,shape=straight,waterlogged=false]",1);
var data=new CompoundTag();data.put("Palette",palette);data.putByteArray(version==3?"Data":"BlockData",new byte[]{0,1});
if(version==3){root.put("Blocks",data);var outer=new CompoundTag();outer.put("Schematic",root);root=outer;}else root.merge(data);
var plan=SchematicIO.decode(root,"Sponge");
h.assertTrue(plan.cells().size()==2&&plan.cells().get(1).state().getValue(BlockStateProperties.HORIZONTAL_FACING)==Direction.EAST,"Sponge v"+version+" reads native state properties");
}
var vanilla=new CompoundTag();vanilla.put("size",ints(1,1,1));
var palette=new ListTag();palette.add(NbtUtils.writeBlockState(Blocks.STONE.defaultBlockState()));vanilla.put("palette",palette);
var block=new CompoundTag();block.put("pos",ints(0,0,0));block.putInt("state",0);var entries=new ListTag();entries.add(block);vanilla.put("blocks",entries);
h.assertTrue(SchematicIO.decode(vanilla,"Vanilla").cells().size()==1,"Vanilla structure imports");
var bad=SchematicIO.encode(fixture());bad.getCompoundOrEmpty("Regions").getCompoundOrEmpty("Main").putLongArray("BlockStates",new long[0]);reject(h,()->SchematicIO.decode(bad,"Bad"));
reject(h,()->SchematicIO.parseState("minecraft:no_such_block"));
reject(h,()->SchematicIO.parseState("minecraft:oak_stairs[facing=garbage]"));
reject(h,()->SchematicIO.parseState("minecraft:stone{foo:1}"));
var newer=SchematicIO.encode(fixture());newer.putInt("MinecraftDataVersion",Integer.MAX_VALUE);reject(h,()->SchematicIO.decode(newer,"Future"));
var huge=SchematicIO.encode(fixture());huge.getCompoundOrEmpty("Regions").getCompoundOrEmpty("Main").getCompoundOrEmpty("Size").putInt("x",Integer.MIN_VALUE);reject(h,()->SchematicIO.decode(huge,"Huge"));
h.succeed();
}
@GameTest public void independentlyWrittenCommunityFormats(GameTestHelper h)throws Exception{
for(String extension:List.of("litematic","schem","nbt")){
try(var stream=getClass().getResourceAsStream("/fixtures/plans072/independent."+extension+".base64.txt")){
h.assertTrue(stream!=null,"Independent fixture packaged: "+extension);
var compressed=new java.io.ByteArrayInputStream(Base64.getMimeDecoder().decode(stream.readAllBytes()));
var plan=SchematicIO.decode(NbtIo.readCompressed(compressed,NbtAccounter.create(SchematicIO.MAX_NBT)),"Independent");
h.assertTrue(plan.width()==5&&plan.height()==2&&plan.depth()==3&&plan.cells().size()==3,"Independent dimensions and sparse block count: "+extension);
var blocks=new HashMap<BlockPos,BlockState>();plan.cells().forEach(c->blocks.put(c.pos(),c.state()));
h.assertTrue(blocks.get(BlockPos.ZERO).is(Blocks.WOOL.red()),"Independent red wool at origin: "+extension);
h.assertTrue(blocks.get(new BlockPos(4,1,2)).getValue(BlockStateProperties.HORIZONTAL_FACING)==Direction.WEST,"Independent UV-free oriented stairs: "+extension);
}
}
h.succeed();
}
@GameTest public void modelGeometryAlphaColorAndRotation(GameTestHelper h){
var texture=new StatueVoxelizer.Texture(2,1,new int[]{0xFFFF0000,0x00000000});
var face=new StatueVoxelizer.Face(new StatueVoxelizer.Vertex(0,0,0,0,0),new StatueVoxelizer.Vertex(1,0,0,1,0),new StatueVoxelizer.Vertex(1,1,0,1,1),new StatueVoxelizer.Vertex(0,1,0,0,1),texture,-1);
var voxels=StatueVoxelizer.convert(List.of(face),16);
h.assertTrue(voxels.keySet().stream().mapToInt(BlockPos::getY).max().orElseThrow()==15,"Requested height preserved");
h.assertTrue(voxels.keySet().stream().noneMatch(p->p.getX()>8),"Transparent texture half never becomes blocks");
h.assertTrue(voxels.values().stream().allMatch(c->c==0xFFFF0000),"UV color sampling is exact without background colors");
var plan=fixture();h.assertTrue(plan.rotate().rotate().rotate().rotate().equals(plan),"Four rotations preserve coordinates and native blockstate direction");
h.succeed();
}
private static ListTag ints(int...values){var list=new ListTag();for(int n:values)list.add(IntTag.valueOf(n));return list;}
@FunctionalInterface private interface Checked {void run()throws Exception;}
private static void reject(GameTestHelper h,Checked work)throws Exception{boolean refused=false;try{work.run();}catch(IOException|IllegalArgumentException e){refused=true;}h.assertTrue(refused,"Invalid input rejected explicitly");}
private static BuildPlan fixture(){return new BuildPlan("Fixture",3,1,1,List.of(new BuildPlan.Cell(BlockPos.ZERO,Blocks.STONE.defaultBlockState()),new BuildPlan.Cell(new BlockPos(1,0,0),Blocks.WOOL.red().defaultBlockState()),new BuildPlan.Cell(new BlockPos(2,0,0),Blocks.OAK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING,Direction.EAST))));}
}
@@ -97,7 +97,7 @@ public final class TerrainAtlas305GameTests {
var report = new LinkedHashMap<String, Object>();
report.put("schema", 1); report.put("version", version);
report.put("revision", System.getProperty("sanctuary.test.atlasRevision", "working-tree"));
report.put("minecraft", "26.3-pre-2"); report.put("seed", Long.toString(seed));
report.put("minecraft", net.minecraft.SharedConstants.getCurrentVersion().id()); report.put("seed", Long.toString(seed));
report.put("diameter", radius * 2); report.put("process_id", ProcessHandle.current().pid());
report.put("domain_y", List.of(0, 419)); report.put("build_ceiling_exclusive", 640);
report.put("map_step", 12); report.put("vertical_step", 2); report.put("density_scale", 10000);
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"sanctuary_gametest:vortex"
]
}
@@ -0,0 +1,43 @@
{
"dimensions": {
"minecraft:overworld": {
"type": "sanctuary:sanctuary_640",
"generator": {
"type": "minecraft:flat",
"settings": {
"biome": "minecraft:plains",
"features": false,
"lakes": false,
"layers": [
{
"block": "minecraft:bedrock",
"height": 1
}
],
"structure_overrides": []
}
}
},
"minecraft:the_end": {
"type": "minecraft:the_end",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:the_end"
},
"settings": "minecraft:end"
}
},
"minecraft:the_nether": {
"type": "minecraft:the_nether",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:multi_noise",
"preset": "minecraft:nether"
},
"settings": "minecraft:nether"
}
}
}
}
@@ -8,7 +8,7 @@
"entrypoints": {
"client": ["fr.koka.sanctuary.gametest.Starter039TestSupport"],
"preLaunch": ["fr.koka.sanctuary.gametest.Inventory010MixinAudit"],
"fabric-gametest": ["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"],
"fabric-gametest": ["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"],
"fabric-client-gametest": ["fr.koka.sanctuary.gametest.SanctuaryClientRenderTests"]
},
"mixins": ["sanctuary-gametest.mixins.json"],
@@ -0,0 +1,19 @@
# Independent schematic fixtures
Synthetic three-block region, authored for Sanctuary on 2026-09-15.
Generated with Litemapy **0.11.0b0**, using its own Litematica, Sponge and vanilla
writers. No downloaded build or Minecraft world is included.
The gzip files are stored as base64 text because this repository's test resource
task applies line filters. The test decodes their exact bytes before parsing.
- Dimensions: 5 × 2 × 3.
- `(0,0,0)` = `minecraft:red_wool`.
- `(1,0,0)` = `minecraft:stone`.
- `(4,1,2)` = `minecraft:oak_stairs[facing=west,half=top,shape=straight,waterlogged=false]`.
- All other cells are air; no entities or block entity contents.
These exercise an independently implemented writer, in addition to Sanctuary's
round-trip and malformed-input tests. Litemapy is a test-fixture generation tool;
it is not a build dependency and is not shipped in Sanctuary.
Source: https://github.com/SmylerMC/litemapy
@@ -0,0 +1 @@
H4sICBRyqWoC/2luZGVwZW5kZW50LmxpdGVtYXRpYwB9kc9O20AQxie1A44dVFKhHiueAIVrby1QCampLBJxjSb22Fmx3o12xw3hZeilj9b36KybxBKH+rLy92n+/b4UIILTR3JeWQMAJxGk83bVC4MILmbKUOGw4ltkPFrZawrJjBhLUVM4uzOFtl6Zeq5eKILBs1QP5d3J+07eFwizEjj50vLaOvg4R1Nwi253yeT5slLP3DpKILslXzi14W5MAvEPbAjO7w2TsxtyuFJa8S6BZG4r3qIjmHxXTA1udsvp1fX11XQ1jSB7oFo63NjWcDgkhmyhGrpxhEylCL9+/3ldxDAO6syWqlK9LOULy6i/als8+bD5QXm0upV1AD5lcJ47+qloe99gTQEOhC+F03+jfQqjB6rIkSlIYOWCJxy1hwN7OLCHEyrj/8IbwaRbaM5yQo6amKUvQHyAdNYcovqMyh3ZfehlR+Vya60+eu97z7M1dDQuesPi09KzdJSL0rwLgRV5ybLCQhKHeCsJSuEadQUR200CQ7/GjXTz7FDVa3GzrWzttK1r4TysUHvBOILkzrAK7cIl8i95aHqjTXIypQzqrl8oieSN8U23quyNMWQ9qBDfYNwBlgqAv/7b+CT0AgAA
@@ -0,0 +1 @@
H4sICBRyqWoC/2luZGVwZW5kZW50Lm5idACNkM9qhDAQhycmbv1DKZQ990F67rn01OsydUcN6xpJBoS+TF+1sbBaitYRAnHmm/n5WQDkYIL9JA0A00njSaa7hvIFGd/JB+t6gPIrh4x6tmwpFJGIk3cDdsRM06vJwLzileD+anuqPNb8jNbDrfy4lD2dT6Nz3dx7WHqBXU9z47g0HF5OgePGGF68eTeQn74kg0ONle0bMCMFjoMtdjVodkMGaWhxiNsCe7RNG7vliEy+c01DZ0hr7AJNIoePzlWXH62nHPTgwu1/zI+O2zgOx6uCdUb9YmCDSXYY9Sdri1EC5r8steKVbDBKsGcva89LCbyUwCtZ8dpilIDZy1KCLCXI2vPSAi8t8NICLy3w0gIvLfAyAi8j8DICLyPwMgIvs+IV698vYo2hTQUAAA==
@@ -0,0 +1 @@
H4sICBRyqWoC/2luZGVwZW5kZW50LnNjaGVtAHWQ0UrDQBBFb7M2aROK4Af4BfkCIS9FQVDRJ30QKUMyu1m67pbdgfg3/qpJK0YfnLd7Zrgzd0pAobomoWeOyQYPVJ8KxY9ClmH5YjvpscyQ37I1vUwwv2dvRqoq5I9aJxZMXr9qjdWNFyuWU3nSm60L7f4PVCifyLEIP9DHqM9KFN9AYfNuPbeRtFyRjafxi5lF7nZDCG7kC4XzuZEkeJ5uV7ibaaD9LslolF41tdabZuAkdU9ONxIOderpwE2SSFPGeiDh6IIx3DWaXOK34/4C62OK6WcjuFxk+L8UvgDKJuEMYQEAAA==
@@ -43,6 +43,9 @@ public final class SanctuaryMod implements ModInitializer {
@Override
public void onInitialize() {
fr.koka.sanctuary.sky.VortexClouds.register();
fr.koka.sanctuary.loading.PreloadProgress.register();
fr.koka.sanctuary.progression.SanctuaryGameplay.register();
fr.koka.sanctuary.cosmetics.AccessoryService.register();
fr.koka.sanctuary.progression.ProgressionService.register();
fr.koka.sanctuary.operator.OperatorService.register();
@@ -66,6 +69,7 @@ public final class SanctuaryMod implements ModInitializer {
fr.koka.sanctuary.notifications.DiscoveryNotifications.register();
fr.koka.sanctuary.realtime.RealtimeService.register();
fr.koka.sanctuary.weather.WeatherService.register();
fr.koka.sanctuary.plans.PlanService.register();
fr.koka.sanctuary.food.FoodSync.register();
fr.koka.sanctuary.food.FoodKnowledgeService.register();
fr.koka.sanctuary.grave.GraveService.register();
@@ -130,6 +134,7 @@ public final class SanctuaryMod implements ModInitializer {
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("cavern_groves"), CavernGrovesFeature.CODEC);
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("cavern_decorations"), CavernDecorationsFeature.CODEC);
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("cavern_shore_sugar_cane"), CavernShoreSugarCaneFeature.CODEC);
fr.koka.sanctuary.multiblock.Multiblocks.register();
LOGGER.info("Sanctuary initialized: the Sanctuary world preset is available.");
}
}
@@ -0,0 +1,136 @@
package fr.koka.sanctuary.arena;
import com.google.gson.*;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.companion.CompanionInventory;
import fr.koka.sanctuary.familiar.FamiliarCatalog;
import fr.koka.sanctuary.progression.AtomicJson;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.*;
import net.fabricmc.fabric.api.attachment.v1.*;
import net.minecraft.nbt.*;
import net.minecraft.resources.RegistryOps;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.storage.LevelResource;
/** Separate additive escrow journal. Each inventory change has a durable native player receipt. */
public final class ArenaLedger {
public static final AttachmentType<String> RECEIPT=AttachmentRegistry.create(SanctuaryMod.id("arena_receipt"),b->b.persistent(com.mojang.serialization.Codec.STRING).copyOnDeath());
private static final int LIMIT=32*1024*1024;
private final MinecraftServer server;
private final Path path;
private byte[] previous;
private JsonObject deposits=new JsonObject(),payouts=new JsonObject();
private boolean healthy=true;
public ArenaLedger(MinecraftServer server)throws Exception{
this.server=server;path=server.getWorldPath(LevelResource.ROOT).resolve("sanctuary/arena-stakes-v1.json");previous=AtomicJson.read(path,LIMIT);
if(previous==null)return;
var root=AtomicJson.parse(new String(previous,StandardCharsets.UTF_8)).getAsJsonObject();AtomicJson.fields(root,"schema","deposits","payouts");
if(root.get("schema").getAsBigDecimal().intValueExact()!=1)throw new IllegalArgumentException("Unknown arena journal");
deposits=root.getAsJsonObject("deposits");payouts=root.getAsJsonObject("payouts");
for(var e:deposits.entrySet()){
UUID.fromString(e.getKey());var d=e.getValue().getAsJsonObject();AtomicJson.fields(d,"player","item","count","match","target","entry","state");validate(d);if(d.get("count").getAsLong()<1||d.get("count").getAsLong()>3456||!d.get("entry").isJsonPrimitive()||!d.get("entry").getAsJsonPrimitive().isBoolean())throw new IllegalArgumentException("Invalid deposit");UUID.fromString(d.get("match").getAsString());UUID.fromString(d.get("target").getAsString());
if(!Set.of("pending","held","refund").contains(d.get("state").getAsString()))throw new IllegalArgumentException("Invalid deposit state");
// Live combats do not survive restart. Decided payouts are already separate and remain authoritative.
d.addProperty("state",d.get("state").getAsString().equals("pending")?"pending":"refund");
}
for(var e:payouts.entrySet()){UUID.fromString(e.getKey());var d=e.getValue().getAsJsonObject();validate(d);
if(d.has("transfer")){AtomicJson.fields(d,"player","item","count","transfer","moving");long moving=d.get("moving").getAsBigDecimal().longValueExact();if(moving<1||moving>3456||moving>d.get("count").getAsLong()||d.get("transfer").getAsString().length()>64)throw new IllegalArgumentException("Invalid transfer");}
else AtomicJson.fields(d,"player","item","count");
}
write();
}
private void validate(JsonObject d){UUID.fromString(d.get("player").getAsString());if(decode(d.get("item")).getCount()!=1)throw new IllegalArgumentException("Invalid unit item");if(d.get("count").getAsBigDecimal().longValueExact()<0)throw new IllegalArgumentException("Negative stake");}
public boolean healthy(){return healthy;}
public boolean pending(ServerPlayer p){return payouts.asMap().values().stream().anyMatch(v->v.getAsJsonObject().get("player").getAsString().equals(p.getStringUUID()))||deposits.asMap().values().stream().anyMatch(v->{var d=v.getAsJsonObject();return d.get("player").getAsString().equals(p.getStringUUID())&&!d.get("state").getAsString().equals("held");});}
public boolean deposit(UUID match,ServerPlayer p,UUID target,boolean entry,ItemStack item,int count){
if(!healthy||!p.isAlive()||p.isRemoved()||item.isEmpty()||count<1||count>3456)return false;
claim(p);if(!healthy||pending(p))return false;
var plan=new CompanionInventory.Plan(p);if(!plan.take(s->ItemStack.isSameItemSameComponents(s,item),count))return false;
try{
var id=UUID.randomUUID().toString();var d=amount(p.getUUID(),item,count);d.addProperty("match",match.toString());d.addProperty("target",target.toString());d.addProperty("entry",entry);d.addProperty("state","pending");deposits.add(id,d);write();
plan.commit();checkpoint(p,id+"/paid");d.addProperty("state","held");write();return true;
}catch(Exception e){fail(e);return false;}
}
public long pot(UUID match,boolean entry){long n=0;for(var v:deposits.asMap().values()){var d=v.getAsJsonObject();if(d.get("match").getAsString().equals(match.toString())&&d.get("entry").getAsBoolean()==entry&&d.get("state").getAsString().equals("held"))n=Math.addExact(n,d.get("count").getAsLong());}return n;}
public boolean hasBet(UUID match,UUID player){return deposits.asMap().values().stream().anyMatch(v->{var d=v.getAsJsonObject();return d.get("match").getAsString().equals(match.toString())&&d.get("player").getAsString().equals(player.toString())&&!d.get("entry").getAsBoolean();});}
public void leave(UUID match,UUID player){
if(!healthy)return;
try{for(var id:new ArrayList<>(deposits.keySet())){var d=deposits.getAsJsonObject(id);
if(!d.get("match").getAsString().equals(match.toString())||!d.get("target").getAsString().equals(player.toString()))continue;
if(!d.get("state").getAsString().equals("held"))throw new IllegalStateException("Unresolved withdrawn stake");
credit(UUID.fromString(d.get("player").getAsString()),decode(d.get("item")),d.get("count").getAsLong());deposits.remove(id);
}write();}catch(Exception e){fail(e);}
}
/** The complete allocation is committed in one write before any player can collect. */
public void finish(UUID match,UUID winner){
if(!healthy)return;
try{
var chosen=new LinkedHashMap<String,JsonObject>();for(var e:deposits.entrySet())if(e.getValue().getAsJsonObject().get("match").getAsString().equals(match.toString()))chosen.put(e.getKey(),e.getValue().getAsJsonObject());
if(chosen.values().stream().anyMatch(d->!d.get("state").getAsString().equals("held"))){for(var d:chosen.values())if(d.get("state").getAsString().equals("held"))d.addProperty("state","refund");write();return;}
if(winner==null){for(var d:chosen.values())credit(UUID.fromString(d.get("player").getAsString()),decode(d.get("item")),d.get("count").getAsLong());}
else{
for(boolean entry:List.of(true,false)){
var pool=chosen.values().stream().filter(d->d.get("entry").getAsBoolean()==entry).toList();if(pool.isEmpty())continue;
long total=0;var weights=new TreeMap<UUID,Long>();
for(var d:pool){long n=d.get("count").getAsLong();total=Math.addExact(total,n);if(d.get("target").getAsString().equals(winner.toString()))weights.merge(UUID.fromString(d.get("player").getAsString()),n,Math::addExact);}
var item=decode(pool.getFirst().get("item"));
if(entry)credit(winner,item,total);
else if(weights.isEmpty())for(var d:pool)credit(UUID.fromString(d.get("player").getAsString()),item,d.get("count").getAsLong());
else for(var allocation:split(total,weights).entrySet())credit(allocation.getKey(),item,allocation.getValue());
}
}
for(var id:chosen.keySet())deposits.remove(id);write();
}catch(Exception e){fail(e);}
}
/** Exact pari-mutuel division; remainder follows stable UUID order. No items are created or rounded away. */
public static Map<UUID,Long> split(long total,SortedMap<UUID,Long> weights){
long sum=0;for(long n:weights.values())sum=Math.addExact(sum,n);if(sum<=0||total<0)throw new IllegalArgumentException("Invalid pot");
var result=new LinkedHashMap<UUID,Long>();long used=0;
for(var e:weights.entrySet()){long n=BigInteger.valueOf(total).multiply(BigInteger.valueOf(e.getValue())).divide(BigInteger.valueOf(sum)).longValueExact();result.put(e.getKey(),n);used=Math.addExact(used,n);}
for(var id:result.keySet()){if(used==total)break;result.put(id,result.get(id)+1);used++;}return result;
}
public void claim(ServerPlayer p){
if(!healthy||!p.isAlive()||p.isRemoved())return;
try{
for(var id:new ArrayList<>(deposits.keySet())){
var d=deposits.getAsJsonObject(id);if(!d.get("player").getAsString().equals(p.getStringUUID()))continue;String state=d.get("state").getAsString();if(state.equals("held"))continue;
if(state.equals("refund")||p.getAttachedOrElse(RECEIPT,"").equals(id+"/paid"))credit(p.getUUID(),decode(d.get("item")),d.get("count").getAsLong());
deposits.remove(id);write();
}
for(var id:new ArrayList<>(payouts.keySet())){
var d=payouts.getAsJsonObject(id);if(!d.get("player").getAsString().equals(p.getStringUUID()))continue;
if(d.has("transfer")){
if(p.getAttachedOrElse(RECEIPT,"").equals(d.get("transfer").getAsString())){checkpoint(p,d.get("transfer").getAsString());d.addProperty("count",d.get("count").getAsLong()-d.get("moving").getAsLong());}
d.remove("transfer");d.remove("moving");write();
}
long left=d.get("count").getAsLong();if(left==0){payouts.remove(id);write();continue;}
var item=decode(d.get("item"));var plan=new CompanionInventory.Plan(p);int capacity=0;
for(int slot:CompanionInventory.slots(p,true)){var at=p.getInventory().getItem(slot);if(at.isEmpty())capacity+=item.getMaxStackSize();else if(ItemStack.isSameItemSameComponents(at,item))capacity+=Math.max(0,item.getMaxStackSize()-at.getCount());}
int moved=(int)Math.min(left,Math.min(capacity,3456));
if(moved>0&&!plan.give(item.copyWithCount(moved)))throw new IllegalStateException("Payout capacity changed");
left-=moved;
if(moved==0)continue;
String receipt=UUID.randomUUID()+"/received";d.addProperty("transfer",receipt);d.addProperty("moving",moved);write();plan.commit();checkpoint(p,receipt);
d.addProperty("count",left);d.remove("transfer");d.remove("moving");if(left==0)payouts.remove(id);write();
}
}catch(Exception e){fail(e);}
}
private JsonObject amount(UUID player,ItemStack item,long count){var d=new JsonObject();d.addProperty("player",player.toString());d.add("item",encode(item.copyWithCount(1)));d.addProperty("count",count);return d;}
private void credit(UUID player,ItemStack item,long count){if(count>0)payouts.add(UUID.randomUUID().toString(),amount(player,item,count));}
private JsonElement encode(ItemStack stack){return ItemStack.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE,server.registryAccess()),stack).getOrThrow();}
private ItemStack decode(JsonElement value){return ItemStack.CODEC.parse(RegistryOps.create(JsonOps.INSTANCE,server.registryAccess()),value).getOrThrow();}
private void write()throws Exception{var root=new JsonObject();root.addProperty("schema",1);root.add("deposits",deposits);root.add("payouts",payouts);previous=AtomicJson.write(path,previous,FamiliarCatalog.JSON.toJson(root),LIMIT);}
private void checkpoint(ServerPlayer p,String receipt)throws Exception{
p.setAttached(RECEIPT,receipt);((fr.koka.sanctuary.mixin.FamiliarPlayerSaveAccess)server.getPlayerList()).sanctuary$saveDuelPlayer(p);
var file=server.getWorldPath(LevelResource.PLAYER_DATA_DIR).resolve(p.getStringUUID()+".dat");
if(!contains(NbtIo.readCompressed(file,NbtAccounter.create(32L*1024*1024)),receipt))throw new IllegalStateException("Arena receipt not saved");
}
private boolean contains(Tag tag,String receipt){if(tag instanceof CompoundTag c){if(c.getString("sanctuary:arena_receipt").orElse("").equals(receipt))return true;for(var key:c.keySet())if(contains(c.get(key),receipt))return true;}else if(tag instanceof ListTag list)for(var child:list)if(contains(child,receipt))return true;return false;}
private void fail(Exception e){healthy=false;SanctuaryMod.LOGGER.error("Arena items paused; journal retained for recovery",e);}
}
@@ -0,0 +1,80 @@
package fr.koka.sanctuary.arena;
import com.google.gson.*;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.companion.CompanionInventory;
import java.util.*;
import net.fabricmc.fabric.api.networking.v1.*;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
public final class ArenaNetwork {
public static final int PAGE_SIZE=12;
public record Request(String action,String id,String target,int revision,int slot,int value,int page,String options) implements CustomPacketPayload {
public static final Type<Request> TYPE=new Type<>(SanctuaryMod.id("arena_request"));
public static final StreamCodec<RegistryFriendlyByteBuf,Request> CODEC=CustomPacketPayload.codec((p,b)->{b.writeUtf(p.action,16);b.writeUtf(p.id,36);b.writeUtf(p.target,36);b.writeVarInt(p.revision);b.writeVarInt(p.slot);b.writeVarInt(p.value);b.writeVarInt(p.page);b.writeUtf(p.options,96);},b->new Request(b.readUtf(16),b.readUtf(36),b.readUtf(36),b.readVarInt(),b.readVarInt(),b.readVarInt(),b.readVarInt(),b.readUtf(96)));
public Type<Request> type(){return TYPE;}
}
public record View(String data,ItemStack currency,List<Integer> slots,List<ItemStack> items,boolean open) implements CustomPacketPayload {
public static final Type<View> TYPE=new Type<>(SanctuaryMod.id("arena_view"));
public static final StreamCodec<RegistryFriendlyByteBuf,View> CODEC=CustomPacketPayload.codec((p,b)->{b.writeUtf(p.data,32767);ItemStack.OPTIONAL_STREAM_CODEC.encode(b,p.currency);b.writeVarInt(p.slots.size());for(int i=0;i<p.slots.size();i++){b.writeVarInt(p.slots.get(i));ItemStack.OPTIONAL_STREAM_CODEC.encode(b,p.items.get(i));}b.writeBoolean(p.open);},b->{var data=b.readUtf(32767);var currency=ItemStack.OPTIONAL_STREAM_CODEC.decode(b);int n=b.readVarInt();if(n<0||n>54)throw new IllegalArgumentException("Invalid arena inventory");var slots=new ArrayList<Integer>();var items=new ArrayList<ItemStack>();for(int i=0;i<n;i++){slots.add(b.readVarInt());items.add(ItemStack.OPTIONAL_STREAM_CODEC.decode(b));}return new View(data,currency,List.copyOf(slots),List.copyOf(items),b.readBoolean());});
public Type<View> type(){return TYPE;}
}
private record Watch(String id,int page,long tick){}
private static final Map<ServerPlayer,Watch> WATCHERS=new WeakHashMap<>();
private static final Map<ServerPlayer,Integer> REQUESTS=new WeakHashMap<>();
private ArenaNetwork(){}
public static void register(){
PayloadTypeRegistry.serverboundPlay().register(Request.TYPE,Request.CODEC);PayloadTypeRegistry.clientboundPlay().register(View.TYPE,View.CODEC);
ServerPlayNetworking.registerGlobalReceiver(Request.TYPE,(r,c)->c.server().execute(()->request(c.player(),r)));
ServerPlayConnectionEvents.DISCONNECT.register((h,s)->{WATCHERS.remove(h.player);REQUESTS.remove(h.player);});
}
public static void request(ServerPlayer p,Request r){
if(r.action.equals("close")){WATCHERS.remove(p);return;}
int tick=p.level().getServer().getTickCount();if(tick-REQUESTS.getOrDefault(p,-100)<3)return;REQUESTS.put(p,tick);
if(p.isSpectator()&&!Set.of("open","refresh","claim").contains(r.action))return;
UUID id=null;try{if(!r.id.isEmpty())id=UUID.fromString(r.id);}catch(IllegalArgumentException e){return;}
var m=id==null?null:Arenas.get(p.level().getServer(),id);String message="";boolean open=r.action.equals("open");
if(m!=null&&!Set.of("open","refresh","claim","bet").contains(r.action)&&r.revision!=m.revision)message="changed";
else try{
message=switch(r.action){
case "open","refresh"->"";
case "create"->{String[] values=r.options.split(",");if(values.length!=5)yield "unavailable";var rules=new Arenas.Rules(Arenas.Mode.valueOf(values[0]),Boolean.parseBoolean(values[1]),Boolean.parseBoolean(values[2]),Integer.parseInt(values[3]),Integer.parseInt(values[4]));yield Arenas.create(p,rules,r.slot,r.value);}
case "join"->Arenas.join(m,p);
case "ready"->Arenas.ready(m,p);
case "start"->Arenas.start(m,p);
case "leave"->{if(m==null||m!=Arenas.match(p))yield "unavailable";Arenas.withdraw(p);yield "updated";}
case "cancel"->{if(m==null||m.legacy||!m.host.equals(p.getUUID())||m.phase!=Arenas.Phase.OPEN)yield "unavailable";Arenas.finish(m,null,"cancelled");yield "updated";}
case "bet"->Arenas.bet(m,p,UUID.fromString(r.target),r.value);
case "claim"->{var ledger=Arenas.ledger(p.level().getServer());if(ledger!=null)ledger.claim(p);yield "updated";}
default->"unavailable";
};
}catch(IllegalArgumentException e){message="unavailable";}
if(message.equals("created"))m=Arenas.match(p);
WATCHERS.put(p,new Watch(m==null?"":m.id.toString(),Math.max(0,Math.min(r.page,1000000)),tick));send(p,message,open);
}
public static void tick(MinecraftServer s){
if(s.getTickCount()%20!=0)return;
for(var p:List.copyOf(WATCHERS.keySet()))if(p.level().getServer()==s){if(s.getTickCount()-WATCHERS.get(p).tick>140||p.isRemoved())WATCHERS.remove(p);else send(p,"",false);}
}
private static String clip(String value){return value.length()>160?value.substring(0,160):value;}
private static JsonObject summary(Arenas.Match m){var d=new JsonObject();d.addProperty("id",m.id.toString());d.addProperty("name",m.name);d.addProperty("phase",m.phase.name());d.addProperty("mode",m.rules.mode().name());d.addProperty("duel",m.rules.duel());d.addProperty("size",m.entrants.size());d.addProperty("entry",m.entry);return d;}
public static void send(ServerPlayer p,String message,boolean open){
if(!ServerPlayNetworking.canSend(p,View.TYPE))return;var watch=WATCHERS.getOrDefault(p,new Watch("",0,0));var data=new JsonObject();data.addProperty("message",message);
var ledger=Arenas.ledger(p.level().getServer());data.addProperty("pending",ledger!=null&&ledger.pending(p));data.addProperty("healthy",ledger!=null&&ledger.healthy());data.addProperty("enrolled",Arenas.enrolled(p));
var events=new ArrayList<>(Arenas.list(p.level().getServer()));events.sort(Comparator.comparingInt(m->m.phase==Arenas.Phase.FINISHED?1:0));
Arenas.Match m=watch.id.isEmpty()?null:Arenas.get(p.level().getServer(),UUID.fromString(watch.id));var currency=ItemStack.EMPTY;
if(m==null){int page=Math.min(watch.page,Math.max(0,(events.size()-1)/PAGE_SIZE));data.addProperty("page",page);data.addProperty("total",events.size());var list=new JsonArray();events.stream().skip((long)page*PAGE_SIZE).limit(PAGE_SIZE).forEach(e->list.add(summary(e)));data.add("events",list);}
else{
var d=summary(m);currency=m.currency.copy();d.addProperty("legacy",m.legacy);d.addProperty("revision",m.revision);d.addProperty("host",m.host.toString());d.addProperty("radius",m.rules.radius());d.addProperty("seconds",m.rules.seconds());d.addProperty("supplies",m.rules.supplies());d.addProperty("remaining",Math.max(0,(m.until-fr.koka.sanctuary.familiar.FamiliarBattle.now()+999)/1000));d.addProperty("dimension",m.level.dimension().identifier().toString());d.addProperty("x",(int)m.center.x);d.addProperty("y",(int)m.center.y);d.addProperty("z",(int)m.center.z);d.addProperty("winner",m.winner==null?"":m.entrants.get(m.winner).name);d.addProperty("reason",m.reason);
d.addProperty("entryPot",m.phase==Arenas.Phase.FINISHED?m.entryPot:ledger==null?0:ledger.pot(m.id,true));d.addProperty("betPot",m.phase==Arenas.Phase.FINISHED?m.betPot:ledger==null?0:ledger.pot(m.id,false));
var own=m.entrants.get(p.getUUID());d.addProperty("participant",own!=null);d.addProperty("ready",own!=null&&own.ready);d.addProperty("out",own!=null&&own.out);
int page=Math.min(watch.page,Math.max(0,(m.entrants.size()-1)/PAGE_SIZE));data.addProperty("page",page);data.addProperty("total",m.entrants.size());var entrants=new JsonArray();m.entrants.values().stream().skip((long)page*PAGE_SIZE).limit(PAGE_SIZE).forEach(e->{var row=new JsonObject();row.addProperty("id",e.id.toString());row.addProperty("name",e.name.length()>160?e.name.substring(0,160):e.name);row.addProperty("ready",e.ready);row.addProperty("out",e.out);row.addProperty("damage",Math.round(e.damage*10)/10F);var bond=fr.koka.sanctuary.familiar.FamiliarBattle.bond(e.player);row.addProperty("pet",bond==null?"":clip(fr.koka.sanctuary.familiar.FamiliarBattle.egg(e.player).getHoverName().getString()));row.addProperty("health",Math.round(e.player.getHealth()*10)/10F);row.addProperty("petHealth",Math.round(fr.koka.sanctuary.familiar.FamiliarLevels.current(bond)*10)/10F);entrants.add(row);});d.add("entrants",entrants);data.add("match",d);
}
var slots=CompanionInventory.slots(p,true);ServerPlayNetworking.send(p,new View(data.toString(),currency,slots,slots.stream().map(i->p.getInventory().getItem(i).copy()).toList(),open));
}
}
@@ -0,0 +1,194 @@
package fr.koka.sanctuary.arena;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.companion.CompanionInventory;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.familiar.*;
import java.util.*;
import net.fabricmc.fabric.api.event.lifecycle.v1.*;
import net.fabricmc.fabric.api.event.player.*;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.*;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.*;
import net.minecraft.world.phys.Vec3;
/** Server-owned free-for-all events. Registration is consent to immutable, publicly visible rules. */
public final class Arenas {
public enum Mode { FAMILIARS, PLAYERS, MIXED }
public enum Phase { OPEN, COUNTDOWN, FIGHT, FINISHED }
public record Rules(Mode mode,boolean duel,boolean supplies,int radius,int seconds) {
public Rules { if(!Set.of(24,48,96).contains(radius)||!Set.of(180,300,600).contains(seconds))throw new IllegalArgumentException("Invalid arena rules"); }
}
public static final class Entrant {
public final UUID id,individual;
public final String name;
public final ServerPlayer player;
public boolean ready,out;
public float damage;
Entrant(ServerPlayer p){player=p;id=p.getUUID();name=p.getDisplayName().getString();individual=FamiliarIdentity.get(FamiliarBattle.egg(p));}
}
public static final class Match {
public final UUID id,host;
public final String name;
public final ServerLevel level;
public final Vec3 center;
public final Rules rules;
public final ItemStack currency;
public final int entry;
public final LinkedHashMap<UUID,Entrant> entrants=new LinkedHashMap<>();
public Phase phase=Phase.OPEN;
public int revision=1;
public long until,entryPot,betPot;
public UUID winner;
public String reason="";
public boolean legacy;
Match(UUID id,ServerPlayer p,Rules rules,ItemStack currency,int entry){this.id=id;host=p.getUUID();name=p.getDisplayName().getString();level=p.level();center=p.position();this.rules=rules;this.currency=currency.copyWithCount(1);this.entry=entry;until=FamiliarBattle.now()+600000;}
public boolean running(){return phase==Phase.COUNTDOWN||phase==Phase.FIGHT;}
}
private static final Map<MinecraftServer,LinkedHashMap<UUID,Match>> WORLDS=new WeakHashMap<>();
private static final Map<MinecraftServer,ArenaLedger> LEDGERS=new WeakHashMap<>();
private static final Map<ServerPlayer,Match> ENROLLED=new WeakHashMap<>();
private static final Map<ServerPlayer,Long> CREATED=new WeakHashMap<>();
private Arenas(){}
public static Component text(String key,Object...args){return Component.translatable("sanctuary.arena."+key,args);}
public static ArenaLedger ledger(MinecraftServer s){return LEDGERS.get(s);}
public static Collection<Match> list(MinecraftServer s){return WORLDS.computeIfAbsent(s,k->new LinkedHashMap<>()).values();}
public static Match get(MinecraftServer s,UUID id){return WORLDS.computeIfAbsent(s,k->new LinkedHashMap<>()).get(id);}
public static Match match(ServerPlayer p){return ENROLLED.get(p);}
public static boolean enrolled(ServerPlayer p){return match(p)!=null;}
public static boolean running(ServerPlayer p){var m=match(p);return m!=null&&!m.legacy&&m.running();}
public static UUID combatId(ServerPlayer p){var m=match(p);return running(p)?m.id:null;}
public static void register(){
ArenaNetwork.register();
ServerLifecycleEvents.SERVER_STARTED.register(s->{try{LEDGERS.put(s,new ArenaLedger(s));}catch(Exception e){SanctuaryMod.LOGGER.error("Arena journal unavailable; file preserved",e);}});
ServerTickEvents.END_SERVER_TICK.register(Arenas::tick);
ServerLifecycleEvents.SERVER_STOPPING.register(s->{for(var m:List.copyOf(list(s)))finish(m,null,"interrupted");});
ServerLifecycleEvents.SERVER_STOPPED.register(s->{WORLDS.remove(s);LEDGERS.remove(s);ENROLLED.keySet().removeIf(p->p.level().getServer()==s);CREATED.keySet().removeIf(p->p.level().getServer()==s);});
ServerLifecycleEvents.END_DATA_PACK_RELOAD.register((s,r,ok)->{if(ok)for(var m:List.copyOf(list(s)))if(!m.legacy)finish(m,null,"interrupted");});
ServerPlayConnectionEvents.DISCONNECT.register((h,s)->{var m=match(h.player);if(m!=null&&!m.legacy){var e=m.entrants.get(h.player.getUUID());if(e!=null&&!e.out)withdraw(h.player);}});
UseBlockCallback.EVENT.register((p,l,h,hit)->p instanceof ServerPlayer s&&running(s)?InteractionResult.FAIL:InteractionResult.PASS);
AttackBlockCallback.EVENT.register((p,l,h,pos,face)->p instanceof ServerPlayer s&&running(s)?InteractionResult.FAIL:InteractionResult.PASS);
UseItemCallback.EVENT.register((p,l,h)->p instanceof ServerPlayer s&&running(s)&&(!match(s).rules.supplies()&&consumable(p.getItemInHand(h))||p.getItemInHand(h).getItem() instanceof BlockItem)?InteractionResult.FAIL:InteractionResult.PASS);
}
public static String create(ServerPlayer p,Rules rules,int slot,int count){
if(enrolled(p)||FamiliarDuels.match(p)!=null||!available(p,rules)||FamiliarBattle.fighting(p))return "unavailable";
if(CREATED.getOrDefault(p,0L)>FamiliarBattle.now())return "wait";
var ledger=ledger(p.level().getServer());if(ledger==null||!ledger.healthy())return "storage";
ItemStack currency=new ItemStack(Items.EMERALD);
if(slot>=0){if(!CompanionInventory.slots(p,true).contains(slot))return "missing";currency=p.getInventory().getItem(slot);if(currency.isEmpty())return "missing";}
if(count<0||count>64)return "missing";
var m=new Match(UUID.randomUUID(),p,rules,currency,count);
if(count>0&&!ledger.deposit(m.id,p,p.getUUID(),true,m.currency,count))return "missing";
m.entrants.put(p.getUUID(),new Entrant(p));WORLDS.computeIfAbsent(p.level().getServer(),k->new LinkedHashMap<>()).put(m.id,m);ENROLLED.put(p,m);CREATED.put(p,FamiliarBattle.now()+10000);announce(m,"announced",p.getDisplayName());return "created";
}
public static boolean consumable(ItemStack stack){return stack.has(net.minecraft.core.component.DataComponents.CONSUMABLE)||stack.is(Items.SPLASH_POTION)||stack.is(Items.LINGERING_POTION)||stack.is(Items.ENDER_PEARL);}
private static boolean available(ServerPlayer p,Rules r){return fr.koka.sanctuary.progression.ProgressEffects.get(p)!=null&&p.isAlive()&&!p.isRemoved()&&!p.isSpectator()&&!p.isCreative()&&(r.mode()==Mode.PLAYERS||FamiliarDuels.ready(p));}
public static boolean inside(Match m,ServerPlayer p){return p.level()==m.level&&p.position().distanceToSqr(m.center)<=m.rules.radius()*m.rules.radius();}
public static String join(Match m,ServerPlayer p){
var ledger=ledger(p.level().getServer());
if(m==null||m.legacy||m.phase!=Phase.OPEN||enrolled(p)||m.entrants.containsKey(p.getUUID())||FamiliarDuels.match(p)!=null||!available(p,m.rules)||FamiliarBattle.fighting(p)||!inside(m,p)||m.rules.duel()&&m.entrants.size()>=2)return "unavailable";
if(ledger==null||ledger.hasBet(m.id,p.getUUID()))return "bettor";
if(m.entry>0&&!ledger.deposit(m.id,p,p.getUUID(),true,m.currency,m.entry))return "missing";
m.entrants.put(p.getUUID(),new Entrant(p));for(var e:m.entrants.values())e.ready=false;ENROLLED.put(p,m);m.revision++;return "joined";
}
public static String ready(Match m,ServerPlayer p){if(m==null||m.legacy||m.phase!=Phase.OPEN||!m.entrants.containsKey(p.getUUID()))return "unavailable";var e=m.entrants.get(p.getUUID());e.ready=!e.ready;m.revision++;return "updated";}
public static String start(Match m,ServerPlayer p){
if(m==null||m.legacy||m.phase!=Phase.OPEN||!m.host.equals(p.getUUID())||m.entrants.size()<2)return "unavailable";
for(var e:m.entrants.values())if(!e.ready||!available(e.player,m.rules)||!inside(m,e.player)||FamiliarBattle.fighting(e.player)||m.rules.mode()!=Mode.PLAYERS&&!Objects.equals(e.individual,FamiliarIdentity.get(FamiliarBattle.egg(e.player))))return "not_ready";
m.phase=Phase.COUNTDOWN;m.until=FamiliarBattle.now()+10000;m.revision++;
for(var e:m.entrants.values()){e.player.stopUsingItem();e.player.stopRiding();e.player.ejectPassengers();fr.koka.sanctuary.mining.MiningManager.interrupt(e.player);fr.koka.sanctuary.building.BuildingManager.interrupt(e.player);FamiliarBattle.cancel(e.player);FamiliarBattle.state(e.player).workMode=false;FamiliarBattle.combat(e.player);}
announce(m,"starting",text("mode."+m.rules.mode().name().toLowerCase(Locale.ROOT)));return "started";
}
public static String bet(Match m,ServerPlayer p,UUID target,int count){
if(m==null||m.phase!=Phase.OPEN||count<1||count>3456||!m.entrants.containsKey(target))return "closed";
if(m.entrants.containsKey(p.getUUID()))return "participant_bet";
var ledger=ledger(p.level().getServer());if(ledger==null||!ledger.deposit(m.id,p,target,false,m.currency,count))return "missing";
return "bet_placed";
}
public static void withdraw(ServerPlayer p){var m=match(p);if(m==null||m.legacy)return;if(m.phase==Phase.OPEN){
if(m.host.equals(p.getUUID())){finish(m,null,"cancelled");return;}
var ledger=ledger(m.level.getServer());if(ledger!=null){ledger.leave(m.id,p.getUUID());if(!ledger.healthy())return;}m.entrants.remove(p.getUUID());ENROLLED.remove(p);for(var e:m.entrants.values())e.ready=false;m.revision++;if(ledger!=null)for(var q:m.level.getServer().getPlayerList().getPlayers())ledger.claim(q);
}else eliminate(p,"forfeit");}
public static void knockedOut(ServerPlayer p){var m=match(p);if(m!=null&&!m.legacy&&m.rules.mode()==Mode.FAMILIARS)eliminate(p,"ko");}
public static void eliminate(ServerPlayer p,String reason){
var m=match(p);if(m==null||m.legacy||!m.running())return;var e=m.entrants.get(p.getUUID());if(e.out)return;e.out=true;m.revision++;FamiliarBattle.cancel(p);p.sendSystemMessage(text("eliminated",text(reason)));
var alive=m.entrants.values().stream().filter(x->!x.out).toList();if(alive.size()<=1)finish(m,alive.isEmpty()?null:alive.getFirst().id,reason);
}
public static void finish(Match m,UUID winner,String reason){
if(m==null||m.phase==Phase.FINISHED)return;m.phase=Phase.FINISHED;m.winner=winner;m.reason=reason;m.until=FamiliarBattle.now()+300000;m.revision++;
var ledger=ledger(m.level.getServer());if(ledger!=null){m.entryPot=ledger.pot(m.id,true);m.betPot=ledger.pot(m.id,false);ledger.finish(m.id,winner);}
for(var e:m.entrants.values()){ENROLLED.remove(e.player,m);FamiliarBattle.cancel(e.player);FamiliarBattle.state(e.player).order=FamiliarBattle.Order.FOLLOW;}
announce(m,"result",winner==null?text("nobody"):Component.literal(m.entrants.get(winner).name));
if(ledger!=null)for(var p:m.level.getServer().getPlayerList().getPlayers())ledger.claim(p);
}
public static Boolean enemy(ServerPlayer p,LivingEntity target){
var m=match(p);var owner=owner(target);var other=owner==null?null:match(owner);
if(m==null||m.legacy)return other!=null&&!other.legacy&&other.running()?false:null;
if(!m.running())return null;
return m.phase==Phase.FIGHT&&m.rules.mode()!=Mode.PLAYERS&&owner!=p&&other==m&&!m.entrants.get(p.getUUID()).out&&!m.entrants.get(owner.getUUID()).out&&(target instanceof FamiliarEntity||m.rules.mode()==Mode.MIXED);
}
public static ServerPlayer owner(Entity e){if(e instanceof FamiliarEntity f)return f.owner();if(e instanceof ServerPlayer p)return p;if(e instanceof net.minecraft.world.entity.projectile.Projectile shot)return owner(shot.getOwner());return null;}
public static Boolean playerEnemy(ServerPlayer p,Player q){
var m=match(p);var other=q instanceof ServerPlayer s?match(s):null;
if((m==null||m.legacy||!m.running())&&(other==null||other.legacy||!other.running()))return null;
return m!=null&&m==other&&!m.legacy&&m.phase==Phase.FIGHT&&m.rules.mode()!=Mode.FAMILIARS&&p!=q&&!m.entrants.get(p.getUUID()).out&&!m.entrants.get(q.getUUID()).out;
}
/** null = ordinary world rules. No damage can cross the consent boundary. */
public static Boolean damageAllowed(LivingEntity target,DamageSource source){
var victim=owner(target);var attacker=owner(source.getEntity());var m=victim==null?null:match(victim);var a=attacker==null?null:match(attacker);
boolean v=m!=null&&!m.legacy&&m.running(),s=a!=null&&!a.legacy&&a.running();
if(!v&&!s)return null;
if(!v)return false;
if(m.phase!=Phase.FIGHT||m.entrants.get(victim.getUUID()).out)return false;
if(target instanceof Player&&m.rules.mode()==Mode.FAMILIARS)return false;
if(target instanceof FamiliarEntity&&m.rules.mode()==Mode.PLAYERS)return false;
if(attacker==null)return true;
if(!s||a!=m||m.entrants.get(attacker.getUUID()).out||attacker==victim)return false;
if(m.rules.mode()==Mode.FAMILIARS&&!(source.getEntity() instanceof FamiliarEntity))return false;
if(m.rules.mode()==Mode.PLAYERS&&source.getEntity() instanceof FamiliarEntity)return false;
return true;
}
/** Count real damage after vanilla armor and absorption. Death remains entirely native. */
public static void afterDamage(LivingEntity entity,DamageSource source,float lost){
var attacker=owner(source.getEntity());if(attacker!=null){var m=match(attacker);if(m!=null&&!m.legacy&&m.phase==Phase.FIGHT)m.entrants.get(attacker.getUUID()).damage+=Math.max(0,lost);}
}
public static void died(ServerPlayer p){var m=match(p);if(m==null||m.legacy)return;if(m.phase==Phase.OPEN)withdraw(p);else if(m.running())eliminate(p,"death");}
public static boolean castAllowed(FamiliarTechniques.Cast cast){var m=match(cast.owner);return m==null||m.legacy||!m.running()||m.phase==Phase.FIGHT&&m.rules.mode()!=Mode.PLAYERS&&!m.entrants.get(cast.owner.getUUID()).out;}
public static void tick(MinecraftServer server){
long now=FamiliarBattle.now();
for(var m:List.copyOf(list(server))){
if(m.phase==Phase.FINISHED){if(now>=m.until)WORLDS.get(server).remove(m.id);continue;}if(m.legacy)continue;
if(now>=m.until){if(m.phase==Phase.COUNTDOWN){m.phase=Phase.FIGHT;m.until=now+m.rules.seconds()*1000L;m.revision++;announce(m,"fight",text("mode."+m.rules.mode().name().toLowerCase(Locale.ROOT)));}else{finish(m,null,m.phase==Phase.OPEN?"expired":"draw");continue;}}
if(!m.running())continue;
for(var e:List.copyOf(m.entrants.values())){
if(!m.running())break;if(e.out)continue;var p=e.player;
if(!inside(m,p)||p.isRemoved()||!p.isAlive()||p.isCreative()||p.isSpectator()){eliminate(p,"left");continue;}
if(m.rules.mode()!=Mode.PLAYERS&&(!Objects.equals(e.individual,FamiliarIdentity.get(FamiliarBattle.egg(p)))||FamiliarBattle.bond(p)!=null&&FamiliarBattle.bond(p).recalled())){eliminate(p,"forfeit");continue;}
if(m.rules.mode()==Mode.FAMILIARS&&!FamiliarDuels.ready(p)){eliminate(p,"ko");continue;}
if(server.getTickCount()%20==0){
FamiliarBattle.combat(p);
if(m.phase==Phase.FIGHT&&m.rules.mode()!=Mode.PLAYERS&&FamiliarBattle.target(p)==null){
LivingEntity nearest=null;double best=24*24;
for(var rival:m.entrants.values())if(!rival.out&&rival!=e){LivingEntity target=FamiliarService.active(rival.player);if(target==null&&m.rules.mode()==Mode.MIXED)target=rival.player;if(target!=null&&target.isAlive()&&enemy(p,target)==Boolean.TRUE&&p.distanceToSqr(target)<best){nearest=target;best=p.distanceToSqr(target);}}
if(nearest!=null)FamiliarBattle.engage(p,nearest,false);
}
}
}
}
ArenaNetwork.tick(server);
}
public static void legacyCreated(FamiliarDuels.Match duel){
var p=duel.players[0];var m=new Match(duel.id,p,new Rules(Mode.FAMILIARS,true,true,24,180),new ItemStack(Items.EMERALD),0);m.legacy=true;
for(var e:duel.players)m.entrants.put(e.getUUID(),new Entrant(e));WORLDS.computeIfAbsent(p.level().getServer(),k->new LinkedHashMap<>()).put(m.id,m);announce(m,"announced",p.getDisplayName());
}
public static void legacyStarted(FamiliarDuels.Match duel){var m=get(duel.players[0].level().getServer(),duel.id);if(m!=null){m.phase=Phase.COUNTDOWN;m.until=duel.until;announce(m,"starting",text("mode.familiars"));}}
public static void legacyFight(FamiliarDuels.Match duel){var m=get(duel.players[0].level().getServer(),duel.id);if(m!=null){m.phase=Phase.FIGHT;m.until=duel.until;}}
public static void legacyFinished(FamiliarDuels.Match duel,int winner,String reason){finish(get(duel.players[0].level().getServer(),duel.id),winner<0?null:duel.players[winner].getUUID(),reason);}
private static void announce(Match m,String key,Component detail){m.level.getServer().getPlayerList().broadcastSystemMessage(text(key,text(m.rules.duel()?"duel":"arena"),m.id.toString().substring(0,8),detail),false);}
}
@@ -0,0 +1,16 @@
package fr.koka.sanctuary.boats;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.entity.vehicle.boat.AbstractBoat;
/** Boat seats remain usable; passengers carried on somebody's head keep their protection. */
public final class BoatPassengerInteractions {
private BoatPassengerInteractions() {}
public static boolean aboardTogether(Entity actor, Entity target) {
return actor instanceof Player && target instanceof Mob && actor.getVehicle() != target
&& target.getVehicle() instanceof AbstractBoat boat && actor.getRootVehicle() == boat;
}
}
@@ -9,6 +9,7 @@ import net.minecraft.world.entity.vehicle.boat.AbstractBoat;
import net.minecraft.world.item.Item;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.phys.AABB;
/** One native vehicle with a fixed rectangular seating plan. Its movement is always simulated by the server. */
public final class CrewBoat extends AbstractBoat {
@@ -44,15 +45,43 @@ public final class CrewBoat extends AbstractBoat {
return seatOffset(Math.max(0, getPassengers().indexOf(passenger))).yRot((float)Math.toRadians(-getYRot()));
}
@Override protected net.minecraft.world.phys.AABB makeBoundingBox(Vec3 at) {
if(columns < 1 || rows < 1 || columns == rows)return super.makeBoundingBox(at);
// TiledBoatModel repeats 28px longitudinal floors, 16px wooden / 20px raft
// floors across. Wooden exterior rails add 2px per side; paddles are decorative.
private double halfHullWidth() { return wood.equals("bamboo") ? columns * 10.0 / 16 : (columns * 8.0 + 2) / 16; }
private double halfHullLength() { return (rows * 14.0 + (wood.equals("bamboo") ? 0 : 2)) / 16; }
@Override protected AABB makeBoundingBox(Vec3 at) {
if(columns < 1 || rows < 1)return super.makeBoundingBox(at); // Entity constructor precedes our fields.
double angle=Math.toRadians(getYRot()), c=Math.abs(Math.cos(angle)), s=Math.abs(Math.sin(angle));
double x=.6875*(columns*c+rows*s), z=.6875*(rows*c+columns*s);
return new net.minecraft.world.phys.AABB(at.x-x,at.y,at.z-z,at.x+x,at.y+getBbHeight(),at.z+z);
return hullBox(at,halfHullWidth()*c+halfHullLength()*s,halfHullLength()*c+halfHullWidth()*s);
}
private AABB hullBox(Vec3 at,double x,double z) {
return new AABB(at.x-x,at.y,at.z-z,at.x+x,at.y+getBbHeight(),at.z+z);
}
@Override public void setYRot(float yaw) {
super.setYRot(yaw);
if(columns>0 && rows>0 && columns!=rows)setBoundingBox(makeBoundingBox(position()));
if(columns>0 && rows>0)setBoundingBox(makeBoundingBox(position()));
}
/** Translation collides natively, but changing yaw must not start that move inside a wall. */
private void turnWithCollisions() {
float target=getYRot()+turn;
if(target==getYRot())return;
double from=Math.toRadians(net.minecraft.util.Mth.wrapDegrees(getYRot()));
double to=from+Math.toRadians(target-getYRot());
var swept=hullBox(position(),sweptExtent(halfHullWidth(),halfHullLength(),from,to),
sweptExtent(halfHullLength(),halfHullWidth(),from,to));
if(level().noCollision(this,swept.deflate(1.0E-7)))setYRot(target);
else turn=0; // Stop angular inertia at contact; keep native tangential/reverse movement.
}
private static double sweptExtent(double a,double b,double from,double to) {
double low=Math.min(from,to),high=Math.max(from,to);
double maximum=Math.max(a*Math.abs(Math.cos(from))+b*Math.abs(Math.sin(from)),
a*Math.abs(Math.cos(to))+b*Math.abs(Math.sin(to)));
// Include extrema between endpoints: checking the target alone misses a corner's arc.
double peak=Math.atan2(b,a);
for(int k=-2;k<=2;k++)for(double angle:new double[]{k*Math.PI+peak,k*Math.PI-peak})
if(angle>=low&&angle<=high)return Math.hypot(a,b);
return maximum;
}
public record Orders(int pilots, double forward, double steering, double lift) { }
@@ -76,7 +105,7 @@ public final class CrewBoat extends AbstractBoat {
var profile = engines.count() == 0 ? null : FlightBalance.profile(engines, FlightMotors.load(this), columns, rows);
double response = profile == null ? .1 : profile.response();
turn += (float)((orders.steering() * 5 - turn) * response);
setYRot(getYRot() + turn);
turnWithCollisions();
double heading = Math.toRadians(getYRot()), throttle = orders.forward();
entityData.set(ROWING, (byte)((throttle != 0 || orders.steering() > 0 ? 1 : 0) | (throttle != 0 || orders.steering() < 0 ? 2 : 0)));
if (profile == null) {
@@ -0,0 +1,31 @@
package fr.koka.sanctuary.building;
import fr.koka.sanctuary.progression.ProgressEffects;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.phys.AABB;
/** Placement-only reach: independent mining, native containers/combat and external range bonuses. */
public final class BuildReach {
private BuildReach() { }
public static double range(Player player) {
double vanilla=player.blockInteractionRange();
var progress=ProgressEffects.get(player);
return progress==null || player.isCreative() || player.isSpectator() ? vanilla
: Math.max(0,vanilla+BuildingLimits.reach(progress.building())-4.5);
}
public static boolean within(Player player,BlockPos support,double buffer) {
double range=range(player)+buffer;
return new AABB(support).distanceToSqr(player.getEyePosition())<range*range;
}
public static double pickRange(Player player) {
double base=Math.max(player.blockInteractionRange(),fr.koka.sanctuary.mining.MiningReach.range(player));
return player.getMainHandItem().getItem() instanceof BlockItem || player.getOffhandItem().getItem() instanceof BlockItem
? Math.max(base,range(player)) : base;
}
public static boolean grouped(Player player) {
return player instanceof ServerPlayer server && BuildingManager.placing(server);
}
}
@@ -39,13 +39,14 @@ public final class BuildSelection {
}
public static BlockHitResult aimed(ServerPlayer player,BlockPos origin) {
if (!player.isAlive() || player.isSpectator() || !usable(player) || !permitted(player,origin)
|| !player.isWithinBlockInteractionRange(origin,0)) return null;
var hit=player.pick(player.blockInteractionRange(),1,false);
|| !BuildReach.within(player,origin,0)) return null;
var hit=player.pick(BuildReach.range(player),1,false);
return hit instanceof BlockHitResult block && hit.getType()!=HitResult.Type.MISS && block.getBlockPos().equals(origin) ? block : null;
}
public static Plan plan(Player player,BlockHitResult hit,int rank) {
if (!usable(player) || rank<1 || rank>BuildingLimits.MAX_RANK || hit.getType()==HitResult.Type.MISS
|| !loaded(player.level(),hit.getBlockPos())) return null;
|| !loaded(player.level(),hit.getBlockPos())
|| player.getEyePosition().distanceToSqr(hit.getLocation())>Math.pow(BuildReach.range(player),2)) return null;
var base=new BlockPlaceContext(player,InteractionHand.MAIN_HAND,player.getMainHandItem(),hit);
BlockPos anchor=base.getClickedPos();
var view=PlacementOrientation.View.capture(player);
@@ -1,9 +1,11 @@
package fr.koka.sanctuary.building;
/** Diameter is independent from the player's native interaction/attack reach. */
/** Surface size and placement reach progress independently; entity reach stays native. */
public final class BuildingLimits {
public static final int MAX_RANK=7;
private static final double[] REACH={3,3.5,4,4.5,4.75,5,5.5,6};
private BuildingLimits() { }
public static double reach(int rank) {return REACH[Math.clamp(rank,0,MAX_RANK)];}
public static int diameter(int rank) {return 1+2*Math.clamp(rank,0,MAX_RANK);}
public static int area(int rank) {int d=diameter(rank);return d*d;}
public static int requested(int requested,int earned) {return Math.clamp(requested,0,Math.clamp(earned,0,MAX_RANK));}
@@ -41,7 +41,7 @@ final class BuildingSession {
|| player.gameMode.getGameModeForPlayer()!=mode || player.containerMenu!=player.inventoryMenu
|| level.getServer().getTickCount()-heartbeat>12 || !sameStack()
|| !BuildSelection.permitted(player,plan.hit().getBlockPos())
|| !player.isWithinBlockInteractionRange(plan.hit().getBlockPos(),0)) return false;
|| !BuildReach.within(player,plan.hit().getBlockPos(),0)) return false;
if (!player.isCreative() && ++ticks%2!=0) return true;
int budget=player.isCreative() ? 16 : 1;
for (int processed=0;index<plan.targets().size() && processed<budget;processed++) {

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